Compare commits

...
Author SHA1 Message Date
Hugo P.Brito 5adf5f6dec fix(api): upgrade sqlparse to 0.6.0 2026-08-22 21:33:37 +01:00
Pablo Fernandez Guerra (PFE) 3e000faa31 feat(ui): add Slack disconnect and revoked-credential recovery (#12437) 2026-08-21 12:42:47 +02:00
Jonathan NguyenandHugo P.Brito f39c92b8f8 feat(bedrock): add model artifact and guardrail grounding security checks for the AWS provider (#12459)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-21 10:50:34 +01:00
Pablo Fernandez Guerra (PFE) 823efc5ab1 feat(ui): pick a Slack channel and verify it (#12436) 2026-08-21 11:45:24 +02:00
Pablo Fernandez Guerra (PFE) 75d7fa5006 feat(ui): add Slack integration connect flow (#12435) 2026-08-21 10:49:46 +02:00
Prowler Botandprowler-bot db25484ccb feat(aws): Update regions for AWS services (#12472)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-20 16:02:56 +01:00
Johannes EnglerandHugo P.Brito 3da4209ee7 feat(stackit): add ske_cluster_no_public_endpoint check (#11943)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-20 13:01:01 +01:00
acb6ff0425 feat(providers/huaweicloud): add vpc_security_group_open_egress check (#12209)
Co-authored-by: tomitobio <tomitobio@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-20 10:52:13 +01:00
Pablo Fernandez Guerra (PFE) ba564af4f4 fix(ci): unblock the Python runtime download in blocked-egress jobs (#12490) 2026-08-20 10:48:16 +02:00
79 changed files with 11018 additions and 190 deletions
+1
View File
@@ -61,6 +61,7 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
+1
View File
@@ -35,6 +35,7 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -57,6 +57,7 @@ jobs:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
+1
View File
@@ -0,0 +1 @@
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
+2 -2
View File
@@ -61,7 +61,7 @@ dependencies = [
"cartography (==0.138.1)",
"gevent (==25.9.1)",
"werkzeug (==3.1.7)",
"sqlparse (==0.5.5)",
"sqlparse (==0.6.0)",
"fonttools (==4.62.1)",
"uvicorn-worker (==0.4.0)"
]
@@ -423,7 +423,7 @@ constraint-dependencies = [
"six==1.17.0",
"slack-sdk==3.39.0",
"sniffio==1.3.1",
"sqlparse==0.5.5",
"sqlparse==0.6.0",
"statsd==4.0.1",
"std-uritemplate==2.0.8",
"stevedore==5.6.0",
Generated
+5 -5
View File
@@ -339,7 +339,7 @@ constraints = [
{ name = "six", specifier = "==1.17.0" },
{ name = "slack-sdk", specifier = "==3.39.0" },
{ name = "sniffio", specifier = "==1.3.1" },
{ name = "sqlparse", specifier = "==0.5.5" },
{ name = "sqlparse", specifier = "==0.6.0" },
{ name = "statsd", specifier = "==4.0.1" },
{ name = "std-uritemplate", specifier = "==2.0.8" },
{ name = "stevedore", specifier = "==5.6.0" },
@@ -5040,7 +5040,7 @@ requires-dist = [
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
{ name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" },
{ name = "sqlparse", specifier = "==0.5.5" },
{ name = "sqlparse", specifier = "==0.6.0" },
{ name = "uuid6", specifier = "==2024.7.10" },
{ name = "uvicorn-worker", specifier = "==0.4.0" },
{ name = "uvloop", specifier = "==0.22.1" },
@@ -6049,11 +6049,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.5.5"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]
@@ -13,7 +13,7 @@ StackIT support in Prowler is community-maintained. For commercial support or to
Before running Prowler with the StackIT provider, ensure you have:
1. A StackIT account with at least one project
2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
2. A StackIT service account key file with project permissions for the services to scan. `iaas.viewer` is sufficient for the currently shipped IaaS checks. For SKE, assign the project-scoped `Reader` role as a broad read-only convenience, or create a custom role with `resource-manager.project.get`, `ske.cluster.list`, `ske.cluster.get`, and `ske.version.list`. The `resource-manager.project.get` permission is optional for scanning; it only lets Prowler verify and display the project name. See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli))
## Prowler CLI
@@ -124,7 +124,9 @@ Prowler outputs findings to the console and writes reports to the `output/` dire
| Service | StackIT API | Description | Example Checks |
|---------|-------------|-------------|----------------|
| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` |
| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted`, `iaas_server_public_ip_attached` |
| **Object Storage** | `objectstorage` | Buckets, object lock, retention policies, access keys | `objectstorage_bucket_object_lock_enabled`, `objectstorage_bucket_retention_policy`, `objectstorage_access_key_expiration` |
| **Kubernetes Engine** | `ske` | Managed Kubernetes clusters | `ske_cluster_no_public_endpoint` |
Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases).
@@ -136,6 +138,8 @@ If the scan fails with a 401 error, the service account key is no longer valid (
### Permission Errors
If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks).
If the connection probe warns that Resource Manager access could not be verified with a 403 response, Prowler continues to service discovery. Grant the optional `resource-manager.project.get` permission to verify and display the project name.
If a service call fails with a 403 response, the service account is missing a permission required by that service. `iaas.viewer` is sufficient for the shipped IaaS checks. The `ske_cluster_no_public_endpoint` check lists clusters and therefore requires `ske.cluster.list`; STACKIT least-privilege custom-role examples also include `ske.cluster.get` and `ske.version.list`. Service API calls remain authoritative and stop the scan when access is denied.
For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication).
@@ -0,0 +1 @@
Bedrock Agent ARNs are now built from the audited partition instead of a hardcoded `arn:aws:`, so findings in GovCloud and China carry a resolvable ARN and `--resource-arn` scoping matches agents in those partitions.
@@ -0,0 +1 @@
`bedrock_guardrail_contextual_grounding_filter_enabled`, `bedrock_custom_model_encrypted_with_cmk`, `bedrock_knowledge_base_encrypted_with_cmk` and `bedrock_agent_role_not_shared_across_agents` are four new AWS Bedrock checks covering guardrail contextual grounding, custom model encryption, knowledge-base data-source encryption, and non-shared agent execution roles.
@@ -0,0 +1 @@
`ske_cluster_no_public_endpoint` check for STACKIT provider, flagging SKE clusters whose Kubernetes API endpoint is reachable from the whole internet because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0`
@@ -0,0 +1 @@
`vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet
@@ -110,7 +110,9 @@
"eks_cluster_kms_cmk_encryption_in_secrets_enabled",
"dynamodb_tables_kms_cmk_encryption_enabled",
"sns_topics_kms_encryption_at_rest_enabled",
"sqs_queues_server_side_encryption_enabled"
"sqs_queues_server_side_encryption_enabled",
"bedrock_custom_model_encrypted_with_cmk",
"bedrock_knowledge_base_encrypted_with_cmk"
]
},
{
@@ -638,10 +640,12 @@
"Section": "AI Application Security",
"SubSection": "Output Validation",
"Service": "bedrock",
"Type": "Manual"
"Type": "Automated"
}
],
"Checks": []
"Checks": [
"bedrock_guardrail_contextual_grounding_filter_enabled"
]
},
{
"Id": "AISF-AI-10",
@@ -652,10 +656,12 @@
"Section": "AI Application Security",
"SubSection": "RAG Security",
"Service": "bedrock",
"Type": "Manual"
"Type": "Automated"
}
],
"Checks": []
"Checks": [
"bedrock_knowledge_base_encrypted_with_cmk"
]
},
{
"Id": "AISF-AI-11",
@@ -680,10 +686,12 @@
"Section": "AI Application Security",
"SubSection": "Agent Governance",
"Service": "bedrock",
"Type": "Manual"
"Type": "Automated"
}
],
"Checks": []
"Checks": [
"bedrock_agent_role_not_shared_across_agents"
]
},
{
"Id": "AISF-AGENT-02",
@@ -3847,6 +3847,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -3856,7 +3857,9 @@
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -3870,6 +3873,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -3952,6 +3956,7 @@
"ca-west-1",
"eu-central-1",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
@@ -4634,6 +4639,7 @@
"regions": {
"aws": [
"ap-south-1",
"ap-southeast-2",
"eu-west-1",
"us-east-1",
"us-west-2"
@@ -4778,7 +4784,9 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
@@ -4963,6 +4971,7 @@
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
@@ -4971,6 +4980,8 @@
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-west-1",
"eu-west-2",
@@ -6231,6 +6242,16 @@
"aws-us-gov": []
}
},
"invoicing": {
"regions": {
"aws": [],
"aws-cn": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-us-gov": []
}
},
"iot": {
"regions": {
"aws": [
@@ -6441,56 +6462,6 @@
]
}
},
"iotevents": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [
"cn-north-1"
],
"aws-eusc": [],
"aws-us-gov": [
"us-gov-west-1"
]
}
},
"iotevents-data": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [
"cn-north-1"
],
"aws-eusc": [],
"aws-us-gov": [
"us-gov-west-1"
]
}
},
"iotfleetwise": {
"regions": {
"aws": [
@@ -7760,6 +7731,7 @@
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-north-1",
"eu-west-1",
@@ -8793,7 +8765,10 @@
],
"aws-cn": [],
"aws-eusc": [],
"aws-us-gov": []
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
}
},
"odb": {
@@ -8826,6 +8801,7 @@
"omics": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-southeast-1",
"eu-central-1",
@@ -8833,6 +8809,7 @@
"eu-west-2",
"il-central-1",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
@@ -9026,6 +9003,7 @@
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -9034,7 +9012,10 @@
],
"aws-cn": [],
"aws-eusc": [],
"aws-us-gov": []
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
}
},
"outposts": {
@@ -9075,21 +9056,6 @@
]
}
},
"panorama": {
"regions": {
"aws": [
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-west-1",
"us-east-1",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-us-gov": []
}
},
"partnercentral-account": {
"regions": {
"aws": [
@@ -9239,6 +9205,7 @@
"aws": [
"af-south-1",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-southeast-1",
@@ -10001,7 +9968,9 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
@@ -10123,7 +10092,10 @@
"us-west-1",
"us-west-2"
],
"aws-cn": [],
"aws-cn": [
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-us-gov": [
"us-gov-east-1",
@@ -10814,7 +10786,9 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
@@ -12580,7 +12554,9 @@
"us-east-1"
],
"aws-cn": [],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-us-gov": []
}
},
@@ -13156,6 +13132,7 @@
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -0,0 +1,42 @@
{
"Provider": "aws",
"CheckID": "bedrock_agent_role_not_shared_across_agents",
"CheckTitle": "Bedrock Agent has a dedicated execution role",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "Every **Bedrock Agent** assumes the role in its `agentResourceRoleArn`. That role must belong to exactly one agent, so no agent inherits another's permissions.",
"Risk": "A **shared execution role** gives every agent using it the union of all their permissions, so a low-trust agent reaching an untrusted tool holds the rights granted for a high-trust one, turning a single **prompt injection** into access it was never scoped for.\n\nIt also destroys **attribution**: **CloudTrail** records the role session, so an action cannot be traced to one agent.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdateAgent.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::Agent\n Properties:\n AgentName: example_resource\n AgentResourceRoleArn: arn:aws:iam::123456789012:role/AmazonBedrockExecutionRoleForAgents_example_resource # Critical: one role per agent, never shared\n```",
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Agents**, then an agent sharing a role\n3. Create a new IAM role trusted by bedrock.amazonaws.com holding only that agent's permissions\n4. Scope its trust policy with aws:SourceArn set to the agent ARN\n5. Edit the agent to use the new role, keeping its other settings, then prepare the agent to cut a version\n6. Open **Aliases** and point every alias at the new version, since an alias left on the old version keeps invoking the shared role\n7. Repeat until each agent has its own role, then remove the unused permissions from the old role",
"Terraform": "```hcl\nresource \"aws_bedrockagent_agent\" \"example_resource\" {\n agent_name = \"example_resource\"\n agent_resource_role_arn = aws_iam_role.example_resource_agent.arn # Critical: one role per agent, never shared\n foundation_model = \"example-model-id\"\n}\n```"
},
"Recommendation": {
"Text": "Give each Bedrock Agent its own execution role scoped to that agent's ARN with aws:SourceArn. Repoint the draft with UpdateAgent, passing every field it already has because omitted ones are not preserved, then PrepareAgent and UpdateAgentAlias to move each alias onto the new version: an alias left on the old version keeps invoking the shared role.",
"Url": "https://hub.prowler.com/check/bedrock_agent_role_not_shared_across_agents"
}
},
"Categories": [
"gen-ai",
"identity-access"
],
"DependsOn": [],
"RelatedTo": [
"bedrock_agent_role_least_privilege"
],
"Notes": "Reports one finding per AGENT, not per role, so a role shared by three agents produces three findings each naming the other two. Sharing is counted across the whole account inventory, so a partial scan can under-report. An agent whose role ARN could not be retrieved from GetAgent returns MANUAL rather than PASS, because an unknown role can be neither confirmed nor ruled out as shared, and it is also excluded from every other agent's share count."
}
@@ -0,0 +1,164 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
bedrock_agent_client,
)
SHARED_ROLE_AGENT_COUNT = 2
class bedrock_agent_role_not_shared_across_agents(Check):
"""Ensure each Bedrock Agent has a dedicated execution role.
A shared `agentResourceRoleArn` means every agent using it inherits the
union of all their permissions, so the least-privileged agent in the set
still holds the rights of the most privileged one. It also destroys
attribution: CloudTrail records the role session, so an action taken with
that role cannot be tied back to a single agent.
Every role an agent can run under counts, not only the working draft's.
GetAgent returns the draft, but an agent version is an immutable snapshot
that keeps the role it was cut with, and an alias routes invocations at a
specific version — so a deployed version can still hold a role the draft no
longer has, and sharing it is the same exposure. Only versions an alias
routes to are considered, since a version no alias points at cannot be
invoked. An alias routing at DRAFT resolves to the draft role already read.
Sharing is judged against the whole account inventory, so the verdict is only
as complete as that inventory. Dedication cannot be asserted while any part of
the picture is missing — an unlisted Region, an agent whose own role could
not be read, or an agent whose deployed versions could not be listed may hold
the same role. A role already seen on two agents is shared whatever else is
missing, so FAIL survives an incomplete inventory.
A scan scoped with ``--resource-arn`` narrows which agents are REPORTED on, not
which agents count towards sharing: the role index is built from the complete
account inventory, so selecting one of two agents that share a role still FAILs.
- PASS: No role this agent holds is used by any other agent, every Region's
agent inventory was listed, every discovered agent's role was readable, and
every agent's deployed versions were listed.
- FAIL: Two or more agents hold the same role, on the draft or on a deployed
version; the other agents holding it are named in the message, and the
version is named when the sharing is through one.
- MANUAL: This agent's own execution role could not be retrieved from
GetAgent; or its roles look dedicated but another agent's role or deployed
versions are unknown, or a Region could not be listed; or ListAgents failed
for a Region, which therefore contributed no agents at all.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
incomplete_regions = sorted(bedrock_agent_client.agents_scan_errors)
for region, error in sorted(bedrock_agent_client.agents_scan_errors.items()):
report = Check_Report_AWS(
metadata=self.metadata(), resource={"region": region}
)
report.region = region
report.resource_id = "agent/unknown"
report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:agent/unknown"
report.status = "MANUAL"
report.status_extended = f"Bedrock Agents could not be listed in region {region} ({error}); verify manually that no execution role is shared between agents."
findings.append(report)
# Keyed on agent ARN, not name, so two same-named agents still count
# twice. An agent is indexed under every role it holds, because a
# deployed version keeps the role it was cut with: sharing through a
# version is the same exposure as sharing through the draft. Indexed over
# all_agents, not the filtered agents; findings are emitted from the
# filtered set below.
agents_by_role = {}
unresolved_agents = []
for agent in bedrock_agent_client.all_agents.values():
entry = (agent.arn, agent.name or agent.id)
for role_arn in self._roles_held_by(agent):
if entry not in agents_by_role.setdefault(role_arn, []):
agents_by_role[role_arn].append(entry)
# Both gaps are recorded, not just the first: an agent can have an
# unreadable draft role and an unlistable version inventory at once,
# and each independently keeps another agent from being called
# dedicated.
if not agent.detail_retrieved or not agent.role_arn:
unresolved_agents.append(
f"the execution role of {agent.name or agent.id}"
)
if not agent.versions_listed:
unresolved_agents.append(
f"deployed versions of {agent.name or agent.id}"
)
# Dedication can only be asserted from a complete picture: an unlisted
# Region, an agent whose role could not be read, or an agent whose
# deployed versions could not be listed may hold the same role.
incomplete = sorted(unresolved_agents) + [
f"agents in region {region}" for region in incomplete_regions
]
for agent in bedrock_agent_client.agents.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=agent)
name = agent.name or agent.id
if not agent.detail_retrieved or not agent.role_arn:
report.status = "MANUAL"
report.status_extended = f"Bedrock Agent {name} execution role could not be retrieved in region {agent.region}; verify manually that no other agent shares it."
findings.append(report)
continue
# Any role this agent holds, on the draft or on a deployed version,
# is a finding when another agent holds it too. Reported on the
# lowest-sorted shared role for determinism across scans.
shared_roles = sorted(
role_arn
for role_arn in self._roles_held_by(agent)
if len(agents_by_role.get(role_arn, [])) >= SHARED_ROLE_AGENT_COUNT
)
if shared_roles:
role_arn = shared_roles[0]
others = sorted(
other_name
for other_arn, other_name in agents_by_role[role_arn]
if other_arn != agent.arn
)
through = (
""
if role_arn == agent.role_arn
else f" through deployed version {sorted(version for version, version_role in agent.version_role_arns.items() if version_role == role_arn)[0]}"
)
report.status = "FAIL"
report.status_extended = f"Bedrock Agent {name} shares execution role {role_arn}{through} with {', '.join(others)} in region {agent.region}, so each agent inherits the union of their permissions and CloudTrail cannot attribute an action to one of them."
elif incomplete:
report.status = "MANUAL"
# Each entry already reads as its own subject, because they are
# not all roles: an entry is an agent whose role is unknown, the
# deployed versions of an agent, or the agents of a Region that
# could not be listed.
report.status_extended = f"Bedrock Agent {name} execution role is used by no other agent whose role could be read in region {agent.region}, but {', '.join(incomplete)} could not be read; verify manually that none of them shares it."
else:
report.status = "PASS"
report.status_extended = f"Bedrock Agent {name} has a dedicated execution role in region {agent.region}."
findings.append(report)
return findings
def _roles_held_by(self, agent) -> set:
"""Collect every execution role an agent can run under.
Args:
agent: The Bedrock Agent to inspect.
Returns:
The working draft's role plus the role of each deployed version an
alias routes to, skipping any that could not be read.
"""
roles = set()
if agent.detail_retrieved and agent.role_arn:
roles.add(agent.role_arn)
roles.update(
role_arn for role_arn in agent.version_role_arns.values() if role_arn
)
return roles
@@ -0,0 +1,43 @@
{
"Provider": "aws",
"CheckID": "bedrock_custom_model_encrypted_with_cmk",
"CheckTitle": "Bedrock custom model is encrypted with a customer-managed KMS key",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/AWS Security Best Practices/Data Encryption"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**Bedrock custom models** produced by model customization can be encrypted at rest with a customer-managed KMS key rather than resting under an AWS-owned key the organization cannot audit, rotate, or revoke.",
"Risk": "A **fine-tuned model** is derived from the training data used to build it and can leak that data through inference.\n\nUnder an **AWS-owned key**, the organization has no key policy to restrict who may decrypt the artifacts, no customer-controlled rotation, no **CloudTrail** record of key usage, and no way to revoke access to the model by disabling a key.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-custom-job.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GetCustomModel.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Custom models**, then create a new customization job\n3. Under encryption, choose a customer-managed KMS key instead of the default\n4. Grant the customization service role kms:Decrypt, kms:GenerateDataKey and kms:DescribeKey on that key\n5. Re-run customization, because the key of an existing custom model cannot be changed in place",
"Terraform": "```hcl\nresource \"aws_bedrock_custom_model\" \"example_resource\" {\n custom_model_name = \"example-custom-model\"\n job_name = \"example-customization-job\"\n base_model_identifier = \"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-express-v1\"\n role_arn = \"arn:aws:iam::123456789012:role/example-bedrock-customization-role\"\n custom_model_kms_key_id = \"arn:aws:kms:us-east-1:123456789012:key/example-key-id\" # Critical: without this the model is encrypted with an AWS owned key\n\n hyperparameters = {\n epochCount = \"1\"\n }\n\n output_data_config {\n s3_uri = \"s3://example-bucket/output/\"\n }\n\n training_data_config {\n s3_uri = \"s3://example-bucket/training/\"\n }\n}\n```"
},
"Recommendation": {
"Text": "Choose a customer-managed KMS key when creating each model customization job so the resulting custom model rests under a key with an auditable policy and rotation. The key cannot be changed after the model is created, so re-run customization for existing models.",
"Url": "https://hub.prowler.com/check/bedrock_custom_model_encrypted_with_cmk"
}
},
"Categories": [
"gen-ai",
"encryption"
],
"DependsOn": [],
"RelatedTo": [
"bedrock_prompt_encrypted_with_cmk"
],
"Notes": "Reports one finding per custom model, reading modelKmsKeyArn from GetCustomModel because ListCustomModels summaries do not carry it. A model whose detail could not be retrieved returns MANUAL rather than PASS, because an absent key ARN means the answer is unknown, not that no key is set."
}
@@ -0,0 +1,54 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
class bedrock_custom_model_encrypted_with_cmk(Check):
"""Ensure Bedrock custom models are encrypted with a customer-managed KMS key.
- PASS: GetCustomModel returns a `modelKmsKeyArn`, so the model artifacts
are encrypted with a key the account controls.
- FAIL: No `modelKmsKeyArn` is set, so the model is encrypted with an
AWS-owned key that the organization cannot audit, rotate, or revoke.
- MANUAL: GetCustomModel failed, so the key could not be retrieved and an
absent value cannot be read as "no key"; or ListCustomModels failed for a
region, so that region's custom models are unknown rather than absent.
Only models this account owns are audited (`ListCustomModels` is called with
`isOwned=True`): the KMS key of a model shared in through Resource Access
Manager belongs to the owning account and cannot be changed here.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for region, error in sorted(bedrock_client.custom_models_scan_errors.items()):
report = Check_Report_AWS(
metadata=self.metadata(), resource={"region": region}
)
report.region = region
report.resource_id = "custom-model/unknown"
report.resource_arn = f"arn:{bedrock_client.audited_partition}:bedrock:{region}:{bedrock_client.audited_account}:custom-model/unknown"
report.status = "MANUAL"
report.status_extended = f"Bedrock custom models could not be listed in region {region} ({error}); verify manually that every custom model uses a customer-managed KMS key."
findings.append(report)
for model in bedrock_client.custom_models.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=model)
if not model.detail_retrieved:
report.status = "MANUAL"
report.status_extended = f"Bedrock custom model {model.name} encryption configuration could not be retrieved in region {model.region}; verify manually that it uses a customer-managed KMS key."
elif model.kms_key_arn:
report.status = "PASS"
report.status_extended = f"Bedrock custom model {model.name} is encrypted with a customer-managed KMS key in region {model.region}."
else:
report.status = "FAIL"
report.status_extended = f"Bedrock custom model {model.name} is not encrypted with a customer-managed KMS key in region {model.region}, so the fine-tuned weights rest under an AWS-owned key the organization cannot audit or revoke."
findings.append(report)
return findings
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "bedrock_guardrail_contextual_grounding_filter_enabled",
"CheckTitle": "Bedrock guardrail blocks ungrounded and irrelevant model responses",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**Bedrock guardrails** can attach a `contextualGroundingPolicy` whose filters score each response for `GROUNDING` (supported by the retrieved source) and `RELEVANCE` (answers the question asked). Both filter types must be enabled, set to `action: BLOCK`, and carry a `threshold` above 0.",
"Risk": "Without both filters the model can return confident, unsupported answers that a **RAG application** presents as sourced fact.\n\nThree settings make a configured filter **inert**: `enabled: false` runs no evaluation, so a `BLOCK` action and tuned threshold sit unused; `action: NONE` scores the response and reports it in the trace while still delivering it; a `threshold` of 0 can never be tripped.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-contextual-grounding-check.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailContextualGroundingFilterConfig.html"
],
"Remediation": {
"Code": {
"CLI": "aws bedrock update-guardrail --guardrail-identifier example-guardrail-id --name example-guardrail --blocked-input-messaging \"Sorry, I cannot answer that.\" --blocked-outputs-messaging \"Sorry, I cannot answer that.\" --contextual-grounding-policy-config '{\"filtersConfig\":[{\"type\":\"GROUNDING\",\"threshold\":0.75,\"action\":\"BLOCK\",\"enabled\":true},{\"type\":\"RELEVANCE\",\"threshold\":0.75,\"action\":\"BLOCK\",\"enabled\":true}]}'",
"NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::Guardrail\n Properties:\n Name: example_resource\n BlockedInputMessaging: Sorry, I cannot answer that.\n BlockedOutputsMessaging: Sorry, I cannot answer that.\n ContextualGroundingPolicyConfig:\n FiltersConfig:\n - Type: GROUNDING\n Threshold: 0.75\n Action: BLOCK # Critical: NONE only reports, it does not block\n Enabled: true # Critical: false runs no evaluation at all\n - Type: RELEVANCE\n Threshold: 0.75\n Action: BLOCK\n Enabled: true\n```",
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Guardrails**, then the guardrail\n3. Edit the contextual grounding check\n4. Enable both the grounding and the relevance filter\n5. Set each threshold above 0 to a value tuned for your corpus\n6. Set each action to Block rather than None\n7. Save and create a new guardrail version",
"Terraform": "```hcl\nresource \"awscc_bedrock_guardrail\" \"example_resource\" {\n name = \"example-guardrail\"\n blocked_input_messaging = \"Sorry, I cannot answer that.\"\n blocked_outputs_messaging = \"Sorry, I cannot answer that.\"\n\n contextual_grounding_policy_config = {\n filters_config = [\n {\n type = \"GROUNDING\"\n threshold = 0.75 # Critical: 0 can never be tripped\n action = \"BLOCK\" # Critical: NONE scores without blocking\n enabled = true # Critical: when false the evaluation never runs\n },\n {\n type = \"RELEVANCE\"\n threshold = 0.75\n action = \"BLOCK\"\n enabled = true\n },\n ]\n }\n}\n```"
},
"Recommendation": {
"Text": "Configure both a GROUNDING and a RELEVANCE contextual grounding filter on every guardrail used by a retrieval-augmented application, set each action to BLOCK, and tune each threshold above 0 against a labelled sample rather than leaving it at the minimum.",
"Url": "https://hub.prowler.com/check/bedrock_guardrail_contextual_grounding_filter_enabled"
}
},
"Categories": [
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [
"bedrock_guardrail_prompt_attack_filter_enabled"
],
"Notes": "Reports one finding per guardrail, reading contextualGroundingPolicy.filters from GetGuardrail. A guardrail whose detail could not be retrieved returns MANUAL rather than PASS, because an absent policy means the answer is unknown, not that the control is satisfied. enabled and action are both optional members with no documented default, so a filter that is otherwise compliant but omits either returns MANUAL rather than being read as blocking or not; an explicit enabled: false or a non-BLOCK action is a definite finding and FAILs."
}
@@ -0,0 +1,136 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
REQUIRED_FILTER_TYPES = frozenset({"GROUNDING", "RELEVANCE"})
BLOCKING_ACTION = "BLOCK"
class bedrock_guardrail_contextual_grounding_filter_enabled(Check):
"""Ensure Bedrock guardrails block ungrounded and irrelevant model responses.
- PASS: The guardrail configures both a GROUNDING and a RELEVANCE
contextual grounding filter, each enabled, with action BLOCK and a
threshold above 0.
- FAIL: No contextual grounding policy is configured; one of the two
required filter types is missing; a present filter carries
enabled: false, so its evaluation never runs; a present filter uses
action NONE, which scores and reports without blocking; or a present
filter has a threshold of 0, which nothing can ever trip.
- MANUAL: GetGuardrail failed, so the policy could not be retrieved and
compliance cannot be asserted from an absent field; or a filter is
otherwise compliant but omits enabled or action, leaving it unknown
whether the evaluation runs or whether it blocks; or ListGuardrails failed
for a Region, so that Region's guardrails are unknown rather than absent.
enabled and action are both optional members of
GuardrailContextualGroundingFilter (only type and threshold are required)
and AWS documents no default for either, so an omitted value is unknown
rather than false or NONE. Reading an omitted enabled as false would FAIL
filters that are in fact evaluating; reading an omitted action as NONE
would assert a misconfiguration the response never stated, and would print
the literal None into status_extended where it reads as an AWS enum value.
Both are therefore reported MANUAL, matching how this check already treats
a guardrail whose detail could not be retrieved. An explicit enabled: false
or an explicit non-BLOCK action is a definite finding and still FAILs, as
does a zero threshold, so an unknown never masks a real one.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for region, error in sorted(bedrock_client.guardrails_scan_errors.items()):
report = Check_Report_AWS(
metadata=self.metadata(), resource={"region": region}
)
report.region = region
report.resource_id = "guardrail/unknown"
report.resource_arn = f"arn:{bedrock_client.audited_partition}:bedrock:{region}:{bedrock_client.audited_account}:guardrail/unknown"
report.status = "MANUAL"
report.status_extended = f"Bedrock guardrails could not be listed in region {region} ({error}); verify manually that each one blocks ungrounded and irrelevant responses."
findings.append(report)
for guardrail in bedrock_client.guardrails.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=guardrail)
if not guardrail.detail_retrieved:
# GetGuardrail failed (permissions, throttling, transient
# error). An absent policy is not evidence of a missing one.
report.status = "MANUAL"
report.status_extended = f"Bedrock Guardrail {guardrail.name} contextual grounding policy could not be retrieved in region {guardrail.region}; verify manually that GROUNDING and RELEVANCE filters block ungrounded responses."
findings.append(report)
continue
filters_by_type = {
filter.type: filter
for filter in guardrail.contextual_grounding_filters
if filter.type
}
if not filters_by_type:
report.status = "FAIL"
report.status_extended = f"Bedrock Guardrail {guardrail.name} has no contextual grounding policy configured in region {guardrail.region}, so ungrounded and irrelevant responses are never detected."
findings.append(report)
continue
missing_types = sorted(REQUIRED_FILTER_TYPES - set(filters_by_type))
if missing_types:
report.status = "FAIL"
# Both required types can be missing at once, so the nouns follow
# the count rather than assuming a single filter.
noun = "filter" if len(missing_types) == 1 else "filters"
classes = "that class" if len(missing_types) == 1 else "those classes"
report.status_extended = f"Bedrock Guardrail {guardrail.name} contextual grounding policy is missing the {', '.join(missing_types)} {noun} in region {guardrail.region}, leaving {classes} of ungrounded response unchecked."
findings.append(report)
continue
reasons = []
unknown_types = []
for filter_type in sorted(REQUIRED_FILTER_TYPES):
filter = filters_by_type[filter_type]
action = filter.action
if filter.enabled is False:
reasons.append(
f"the {filter_type} filter is disabled, so its evaluation never runs regardless of its action or threshold"
)
elif action is not None and action != BLOCKING_ACTION:
reasons.append(
f"the {filter_type} filter uses action {action} instead of BLOCK, so it scores and reports without blocking"
)
elif not (filter.threshold or 0) > 0:
reasons.append(
f"the {filter_type} filter has a threshold of {filter.threshold}, which no response can ever trip"
)
elif action is None or filter.enabled is None:
# Name only the attributes actually omitted: one of the two
# may be present, and claiming both are missing describes a
# response the guardrail did not return.
missing = [
attribute
for attribute, value in (
("enabled", filter.enabled),
("action", action),
)
if value is None
]
unknown_types.append(
f"{filter_type} filter omits {' and '.join(missing)}"
)
if reasons:
report.status = "FAIL"
report.status_extended = f"Bedrock Guardrail {guardrail.name} does not block ungrounded responses in region {guardrail.region}: {'; '.join(reasons)}."
elif unknown_types:
report.status = "MANUAL"
subject = "it blocks" if len(unknown_types) == 1 else "they block"
report.status_extended = f"Bedrock Guardrail {guardrail.name} has both required contextual grounding filters with a non-zero threshold in region {guardrail.region}, but the {', '.join(unknown_types)}, so whether {subject} is unknown; verify manually that the evaluation runs and blocks."
else:
report.status = "PASS"
report.status_extended = f"Bedrock Guardrail {guardrail.name} blocks ungrounded and irrelevant responses with GROUNDING and RELEVANCE filters in region {guardrail.region}."
findings.append(report)
return findings
@@ -0,0 +1,43 @@
{
"Provider": "aws",
"CheckID": "bedrock_knowledge_base_encrypted_with_cmk",
"CheckTitle": "Bedrock knowledge base data source is encrypted with a customer-managed KMS key",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/AWS Security Best Practices/Data Encryption"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "Each data source of a **Bedrock knowledge base** can set `serverSideEncryptionConfiguration.kmsKeyArn`, which encrypts the transient storage used while documents are chunked and embedded, rather than relying on an AWS-owned key.",
"Risk": "**Knowledge base data sources** carry the private corpus a **RAG application** answers from, often the most sensitive content in the workload.\n\nUnder an **AWS-owned key**, there is no key policy restricting who may decrypt the ingestion storage, no account-controlled rotation, no **CloudTrail** record of key usage, and no way to revoke access by disabling a key.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_GetDataSource.html"
],
"Remediation": {
"Code": {
"CLI": "aws bedrock-agent update-data-source --knowledge-base-id example-kb-id --data-source-id example-ds-id --name example-data-source --data-source-configuration '{\"type\":\"S3\",\"s3Configuration\":{\"bucketArn\":\"arn:aws:s3:::example-bucket\"}}' --server-side-encryption-configuration '{\"kmsKeyArn\":\"arn:aws:kms:us-east-1:123456789012:key/example-key-id\"}'",
"NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::DataSource\n Properties:\n Name: example_resource\n KnowledgeBaseId: ABCDE12345\n DataSourceConfiguration:\n Type: S3\n S3Configuration:\n BucketArn: arn:aws:s3:::example-bucket\n ServerSideEncryptionConfiguration:\n KmsKeyArn: arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab # Critical: omitting this uses an AWS-owned key\n```",
"Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Knowledge bases**, then the knowledge base\n3. Select the data source and choose Edit\n4. Under transient data encryption, choose a customer-managed KMS key\n5. Grant the knowledge base service role kms:Decrypt and kms:GenerateDataKey on that key\n6. Save and re-sync the data source",
"Terraform": "```hcl\nresource \"aws_bedrockagent_data_source\" \"example_resource\" {\n name = \"example_resource\"\n knowledge_base_id = \"ABCDE12345\"\n data_source_configuration {\n type = \"S3\"\n s3_configuration {\n bucket_arn = \"arn:aws:s3:::example-bucket\"\n }\n }\n server_side_encryption_configuration {\n kms_key_arn = \"arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab\" # Critical: omitting this uses an AWS-owned key\n }\n}\n```"
},
"Recommendation": {
"Text": "Set a customer-managed KMS key on every knowledge base data source so the transient ingestion storage rests under a key with an auditable policy, and grant the knowledge base service role only kms:Decrypt and kms:GenerateDataKey on it.",
"Url": "https://hub.prowler.com/check/bedrock_knowledge_base_encrypted_with_cmk"
}
},
"Categories": [
"gen-ai",
"encryption"
],
"DependsOn": [],
"RelatedTo": [
"bedrock_custom_model_encrypted_with_cmk"
],
"Notes": "Reports one finding per DATA SOURCE, not per knowledge base, because the key is configured on the data source; the knowledge base name is carried into the message. GetDataSource nests its payload under a top-level dataSource object. A data source whose detail could not be retrieved returns MANUAL rather than PASS, because an absent key ARN means the answer is unknown, not that no key is set."
}
@@ -0,0 +1,80 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
bedrock_agent_client,
)
class bedrock_knowledge_base_encrypted_with_cmk(Check):
"""Ensure Bedrock knowledge base data sources are encrypted with a CMK.
One finding is reported per data source, because the key is configured on
the data source rather than on the knowledge base.
- PASS: The data source sets `serverSideEncryptionConfiguration.kmsKeyArn`,
so the transient storage used during ingestion is encrypted with a key
the account controls.
- FAIL: No `kmsKeyArn` is set, so the ingested documents rest under an
AWS-owned key the organization cannot audit, rotate, or revoke.
- MANUAL: GetDataSource failed, so the key could not be retrieved and an
absent value cannot be read as "no key"; or ListDataSources failed for the
knowledge base, so its data sources are unknown; or ListKnowledgeBases
failed for a region, so the region's knowledge bases are unknown.
A knowledge base whose data sources could not be listed is reported against
the knowledge base itself. Reporting nothing would drop it from the output
entirely, which reads as "no data sources to flag" and is indistinguishable
from a clean result.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for region, error in sorted(
bedrock_agent_client.knowledge_bases_scan_errors.items()
):
report = Check_Report_AWS(
metadata=self.metadata(), resource={"region": region}
)
report.region = region
report.resource_id = "knowledge-base/unknown"
report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:knowledge-base/unknown"
report.status = "MANUAL"
report.status_extended = f"Bedrock knowledge bases could not be listed in region {region} ({error}); verify manually that every knowledge base data source uses a customer-managed KMS key."
findings.append(report)
for knowledge_base in bedrock_agent_client.knowledge_bases.values():
if knowledge_base.data_sources_listed:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=knowledge_base)
report.status = "MANUAL"
reason = (
f" ({knowledge_base.data_sources_error})"
if knowledge_base.data_sources_error
else ""
)
report.status_extended = f"Bedrock knowledge base {knowledge_base.name} data sources could not be listed in region {knowledge_base.region}{reason}; verify manually that each one uses a customer-managed KMS key."
findings.append(report)
for data_source in bedrock_agent_client.data_sources.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=data_source)
knowledge_base = (
data_source.knowledge_base_name or data_source.knowledge_base_id
)
if not data_source.detail_retrieved:
report.status = "MANUAL"
report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} encryption configuration could not be retrieved in region {data_source.region}; verify manually that it uses a customer-managed KMS key."
elif data_source.kms_key_arn:
report.status = "PASS"
report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} is encrypted with a customer-managed KMS key in region {data_source.region}."
else:
report.status = "FAIL"
report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} is not encrypted with a customer-managed KMS key in region {data_source.region}, so ingested documents rest under an AWS-owned key the organization cannot audit or revoke."
findings.append(report)
return findings
@@ -16,10 +16,14 @@ class Bedrock(AWSService):
self.guardrails = {}
self.guardrails_scanned_regions = set()
self.guardrails_scan_errors = {}
self.custom_models = {}
self.custom_models_scan_errors = {}
self.__threading_call__(self._get_model_invocation_logging_configuration)
self.__threading_call__(self._list_guardrails)
self.__threading_call__(self._get_guardrail, self.guardrails.values())
self.__threading_call__(self._list_tags_for_resource, self.guardrails.values())
self.__threading_call__(self._list_custom_models)
self.__threading_call__(self._get_custom_model, self.custom_models.values())
def _get_model_invocation_logging_arn_template(self, region):
return (
@@ -56,6 +60,7 @@ class Bedrock(AWSService):
)
def _list_guardrails(self, regional_client):
"""List the guardrails in a region."""
logger.info("Bedrock - Listing Guardrails...")
try:
paginator = regional_client.get_paginator("list_guardrails")
@@ -72,9 +77,11 @@ class Bedrock(AWSService):
)
self.guardrails_scanned_regions.add(regional_client.region)
except ClientError as error:
self.guardrails_scan_errors[regional_client.region] = error.response[
"Error"
].get("Code", error.__class__.__name__)
code = error.response["Error"].get("Code", error.__class__.__name__)
# ValidationException means Bedrock is unavailable in the region:
# a definite "no guardrails", so it must not become a MANUAL finding.
if code != "ValidationException":
self.guardrails_scan_errors[regional_client.region] = code
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -100,6 +107,19 @@ class Bedrock(AWSService):
guardrail.prompt_attack_filter_strength = filter.get(
"inputStrength", "NONE"
)
# Absent from the response when no grounding policy is configured.
guardrail.contextual_grounding_filters = [
ContextualGroundingFilter(
type=filter.get("type"),
threshold=filter.get("threshold"),
action=filter.get("action"),
enabled=filter.get("enabled"),
)
for filter in guardrail_info.get("contextualGroundingPolicy", {}).get(
"filters", []
)
]
guardrail.detail_retrieved = True
except Exception as error:
logger.error(
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -118,6 +138,64 @@ class Bedrock(AWSService):
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_custom_models(self, regional_client):
"""List the customized models owned by the audited account.
isOwned=True is required: without it the response also carries models
shared into this account through Resource Access Manager, whose KMS key
the audited account does not own and cannot set, so auditing them
produces a finding nobody here can remediate.
"""
logger.info("Bedrock - Listing Custom Models...")
try:
paginator = regional_client.get_paginator("list_custom_models")
for page in paginator.paginate(isOwned=True):
for model in page.get("modelSummaries", []):
model_arn = model.get("modelArn", "")
if model_arn and (
not self.audit_resources
or is_resource_filtered(model_arn, self.audit_resources)
):
self.custom_models[model_arn] = CustomModel(
name=model.get("modelName", ""),
arn=model_arn,
region=regional_client.region,
)
except ClientError as error:
code = error.response["Error"].get("Code", error.__class__.__name__)
# ValidationException means Bedrock is unavailable in the region:
# a definite "no models", so it must not become a MANUAL finding.
if code != "ValidationException":
self.custom_models_scan_errors[regional_client.region] = code
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.custom_models_scan_errors[regional_client.region] = (
error.__class__.__name__
)
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_custom_model(self, model):
"""Fetch the KMS key a custom model's weights are encrypted with.
list_custom_models summaries do not carry modelKmsKeyArn, so a
per-model GetCustomModel call is required.
"""
logger.info("Bedrock - Getting Custom Model...")
try:
model_info = self.regional_clients[model.region].get_custom_model(
modelIdentifier=model.arn
)
model.kms_key_arn = model_info.get("modelKmsKeyArn")
model.detail_retrieved = True
except Exception as error:
logger.error(
f"{model.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class LoggingConfiguration(BaseModel):
enabled: bool = False
@@ -125,6 +203,19 @@ class LoggingConfiguration(BaseModel):
s3_bucket: Optional[str] = None
class ContextualGroundingFilter(BaseModel):
"""One filter of a guardrail's contextualGroundingPolicy.
type and threshold are required by the API; enabled and action are optional
and have no documented default, so an absent value means unknown.
"""
type: Optional[str] = None
threshold: Optional[float] = None
action: Optional[str] = None
enabled: Optional[bool] = None
class Guardrail(BaseModel):
id: str
name: str
@@ -133,6 +224,20 @@ class Guardrail(BaseModel):
tags: Optional[list] = []
sensitive_information_filter: bool = False
prompt_attack_filter_strength: Optional[str] = None
contextual_grounding_filters: list[ContextualGroundingFilter] = []
# False when GetGuardrail failed: absent policy is unknown, not unset.
detail_retrieved: bool = False
class CustomModel(BaseModel):
"""Model representing a Bedrock customized (fine-tuned) model."""
name: str
arn: str
region: str
kms_key_arn: Optional[str] = None
# False when GetCustomModel failed: absent key is unknown, not unset.
detail_retrieved: bool = False
class BedrockAgent(AWSService):
@@ -143,13 +248,28 @@ class BedrockAgent(AWSService):
# Call AWSService's __init__
super().__init__("bedrock-agent", provider)
self.agents = {}
# Every agent in the account, including those --resource-arn excluded. A check whose verdict
# for one agent depends on another (role sharing) cannot read self.agents: the agent that
# proves the sharing may be the one the operator filtered out. Same shape as
# cloudwatch_service's all_log_groups/log_groups pair -- one object, two dicts.
self.all_agents = {}
self.prompts = {}
self.knowledge_bases = {}
self.data_sources = {}
self.knowledge_bases_scan_errors = {}
self.agents_scan_errors = {}
self.prompt_scanned_regions: set = set()
self.__threading_call__(self._list_agents)
self.__threading_call__(self._get_agent, self.agents.values())
# Detail collection runs over the COMPLETE inventory: an out-of-scope agent's role still
# determines whether an in-scope agent's role is shared.
self.__threading_call__(self._get_agent, self.all_agents.values())
self.__threading_call__(self._get_agent_version_roles, self.all_agents.values())
self.__threading_call__(self._list_prompts)
self.__threading_call__(self._get_prompt, self.prompts.values())
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
self.__threading_call__(self._list_knowledge_bases)
self.__threading_call__(self._list_data_sources, self.knowledge_bases.values())
self.__threading_call__(self._get_data_source, self.data_sources.values())
def _list_agents(self, regional_client):
logger.info("Bedrock Agent - Listing Agents...")
@@ -157,20 +277,32 @@ class BedrockAgent(AWSService):
paginator = regional_client.get_paginator("list_agents")
for page in paginator.paginate():
for agent in page.get("agentSummaries", []):
agent_arn = f"arn:aws:bedrock:{regional_client.region}:{self.audited_account}:agent/{agent['agentId']}"
agent_arn = f"arn:{self.audited_partition}:bedrock:{regional_client.region}:{self.audited_account}:agent/{agent['agentId']}"
agent_object = Agent(
id=agent["agentId"],
name=agent["agentName"],
arn=agent_arn,
guardrail_id=agent.get("guardrailConfiguration", {}).get(
"guardrailIdentifier"
),
region=regional_client.region,
)
self.all_agents[agent_arn] = agent_object
if not self.audit_resources or (
is_resource_filtered(agent_arn, self.audit_resources)
):
self.agents[agent_arn] = Agent(
id=agent["agentId"],
name=agent["agentName"],
arn=agent_arn,
guardrail_id=agent.get("guardrailConfiguration", {}).get(
"guardrailIdentifier"
),
region=regional_client.region,
)
self.agents[agent_arn] = agent_object
except ClientError as error:
code = error.response["Error"].get("Code", error.__class__.__name__)
# ValidationException means Bedrock Agent is unavailable in the
# region: a definite "no agents", so it must not become MANUAL.
if code != "ValidationException":
self.agents_scan_errors[regional_client.region] = code
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.agents_scan_errors[regional_client.region] = error.__class__.__name__
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -186,11 +318,86 @@ class BedrockAgent(AWSService):
try:
agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id)
agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn")
agent.detail_retrieved = True
except Exception as error:
logger.error(
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
# PREPARED is the only status AWS documents as ready to invoke. CREATING and
# UPDATING are in-flight, so their routed versions are unknown rather than
# definitely active or inactive.
TRANSITIONAL_ALIAS_STATUSES = {"CREATING", "UPDATING"}
@staticmethod
def _is_alias_active(alias: dict) -> bool:
"""Can this alias actually invoke the version it routes to?
Args:
alias: One agentAliasSummaries entry from ListAgentAliases.
Returns:
True only when the alias is prepared and does not reject invocations.
"""
if alias.get("aliasInvocationState") == "REJECT_INVOCATIONS":
return False
return alias.get("agentAliasStatus") == "PREPARED"
def _get_agent_version_roles(self, agent):
"""Fetch the execution role of every agent version an ACTIVE alias routes to.
GetAgent returns only the working draft. An agent version is an
immutable snapshot that keeps the role it was cut with, and an alias
routes invocations at a specific version, so a deployed version can
still hold a role the draft no longer has. Only versions an active alias
routes to are fetched: a version nothing can invoke is not live
exposure, so reporting on it would be a false FAIL.
"""
logger.info("Bedrock Agent - Getting Agent Version Roles...")
try:
client = self.regional_clients[agent.region]
paginator = client.get_paginator("list_agent_aliases")
routed_versions = set()
inventory_complete = True
for page in paginator.paginate(agentId=agent.id):
for alias in page.get("agentAliasSummaries", []):
if (
alias.get("agentAliasStatus")
in self.TRANSITIONAL_ALIAS_STATUSES
):
inventory_complete = False
continue
if not self._is_alias_active(alias):
continue
for route in alias.get("routingConfiguration", []):
version = route.get("agentVersion")
# agentVersion is an optional member of the routing
# configuration, and DRAFT routes at the working draft
# whose role GetAgent already captured.
if version and version != "DRAFT":
routed_versions.add(version)
for version in sorted(routed_versions):
version_info = client.get_agent_version(
agentId=agent.id, agentVersion=version
)
agent.version_role_arns[version] = version_info.get(
"agentVersion", {}
).get("agentResourceRoleArn")
agent.versions_listed = inventory_complete
except ClientError as error:
agent.versions_error = error.response["Error"].get(
"Code", error.__class__.__name__
)
logger.error(
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
agent.versions_error = error.__class__.__name__
logger.error(
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_prompts(self, regional_client):
"""List all prompts in a region."""
logger.info("Bedrock Agent - Listing Prompts...")
@@ -229,6 +436,111 @@ class BedrockAgent(AWSService):
f"{prompt.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_knowledge_bases(self, regional_client):
"""List the knowledge bases in a region."""
logger.info("Bedrock Agent - Listing Knowledge Bases...")
try:
paginator = regional_client.get_paginator("list_knowledge_bases")
for page in paginator.paginate():
for knowledge_base in page.get("knowledgeBaseSummaries", []):
knowledge_base_id = knowledge_base.get("knowledgeBaseId", "")
if not knowledge_base_id:
continue
knowledge_base_arn = f"arn:{self.audited_partition}:bedrock:{regional_client.region}:{self.audited_account}:knowledge-base/{knowledge_base_id}"
if not self.audit_resources or is_resource_filtered(
knowledge_base_arn, self.audit_resources
):
self.knowledge_bases[knowledge_base_arn] = KnowledgeBase(
id=knowledge_base_id,
name=knowledge_base.get("name", ""),
arn=knowledge_base_arn,
region=regional_client.region,
)
except ClientError as error:
code = error.response["Error"].get("Code", error.__class__.__name__)
# ValidationException means Bedrock Agent is unavailable in the
# region: a definite "none", so it must not become a MANUAL finding.
if code != "ValidationException":
self.knowledge_bases_scan_errors[regional_client.region] = code
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.knowledge_bases_scan_errors[regional_client.region] = (
error.__class__.__name__
)
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_data_sources(self, knowledge_base):
"""List the data sources attached to one knowledge base.
A failure is recorded on the knowledge base itself: findings are per data
source, so an unlisted knowledge base would otherwise vanish from the report.
"""
logger.info("Bedrock Agent - Listing Data Sources...")
try:
paginator = self.regional_clients[knowledge_base.region].get_paginator(
"list_data_sources"
)
for page in paginator.paginate(knowledgeBaseId=knowledge_base.id):
for data_source in page.get("dataSourceSummaries", []):
data_source_id = data_source.get("dataSourceId", "")
if not data_source_id:
continue
data_source_arn = (
f"{knowledge_base.arn}/data-source/{data_source_id}"
)
# No filter here: the parent knowledge base was already
# filtered on its own ARN, and this ARN is synthetic because
# AWS exposes none for a data source, so it could never match
# a user-supplied --resource-arn and would silently drop every
# data source of an in-scope knowledge base.
self.data_sources[data_source_arn] = KnowledgeBaseDataSource(
id=data_source_id,
name=data_source.get("name", ""),
arn=data_source_arn,
region=knowledge_base.region,
knowledge_base_id=knowledge_base.id,
knowledge_base_name=knowledge_base.name,
)
knowledge_base.data_sources_listed = True
except ClientError as error:
knowledge_base.data_sources_error = error.response["Error"].get(
"Code", error.__class__.__name__
)
logger.error(
f"{knowledge_base.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
knowledge_base.data_sources_error = error.__class__.__name__
logger.error(
f"{knowledge_base.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_data_source(self, data_source):
"""Fetch the KMS key a data source's transient storage is encrypted with."""
logger.info("Bedrock Agent - Getting Data Source...")
try:
# GetDataSource nests everything under a top-level dataSource object.
data_source_info = (
self.regional_clients[data_source.region]
.get_data_source(
knowledgeBaseId=data_source.knowledge_base_id,
dataSourceId=data_source.id,
)
.get("dataSource", {})
)
data_source.kms_key_arn = data_source_info.get(
"serverSideEncryptionConfiguration", {}
).get("kmsKeyArn")
data_source.detail_retrieved = True
except Exception as error:
logger.error(
f"{data_source.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_tags_for_resource(self, resource):
"""List tags for a Bedrock Agent resource."""
logger.info("Bedrock Agent - Listing Tags for Resource...")
@@ -256,6 +568,17 @@ class Agent(BaseModel):
role_arn: Optional[str] = None
region: str
tags: Optional[list] = []
# False when GetAgent failed: absent role is unknown, not unset.
detail_retrieved: bool = False
# Execution role of each numbered version an alias routes to, keyed by
# version. A version is an immutable snapshot, so it keeps the role it was
# cut with even after the working draft's role changes.
version_role_arns: dict = {}
# True once the alias and version inventory was read in full, so an agent
# with no deployed versions genuinely has none rather than that they could
# not be listed.
versions_listed: bool = False
versions_error: Optional[str] = None
class Prompt(BaseModel):
@@ -266,3 +589,30 @@ class Prompt(BaseModel):
arn: str
region: str
customer_encryption_key_arn: Optional[str] = None
class KnowledgeBase(BaseModel):
"""Model representing a Bedrock Agent knowledge base."""
id: str
name: str
arn: str
region: str
# False when ListDataSources failed: empty set is unknown, not none.
data_sources_listed: bool = False
# The error code from a failed ListDataSources, for the finding message.
data_sources_error: Optional[str] = None
class KnowledgeBaseDataSource(BaseModel):
"""Model representing a data source attached to a Bedrock knowledge base."""
id: str
name: str
arn: str
region: str
knowledge_base_id: str
knowledge_base_name: Optional[str] = None
kms_key_arn: Optional[str] = None
# False when GetDataSource failed: absent key is unknown, not unset.
detail_retrieved: bool = False
@@ -0,0 +1,36 @@
{
"Provider": "huaweicloud",
"CheckID": "vpc_security_group_open_egress",
"CheckTitle": "VPC security groups do not allow open egress to the internet",
"CheckType": [],
"ServiceName": "vpc",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "HUAWEICLOUD::VPC::SecurityGroup",
"ResourceGroup": "network",
"Description": "Security groups should not allow unrestricted egress to all destinations. Huawei Cloud represents unrestricted destinations as 0.0.0.0/0, ::/0, or an empty remote IP prefix without a remote security group or address group. Open egress allows instances to reach any destination on the internet, increasing the risk of data exfiltration and command-and-control communication.",
"Risk": "Unrestricted egress rules allow instances to communicate with any external destination. This increases the risk of data exfiltration, unauthorized outbound connections, and command-and-control channel establishment by compromised instances.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.huaweicloud.com/intl/en-us/usermanual-vpc/vpc_SecurityGroup_0001.html"
],
"Remediation": {
"Code": {
"CLI": "hcloud VPC UpdateSecurityGroupRule --security_group_rule_id <rule_id> --remote_ip_prefix <restricted_cidr>",
"NativeIaC": "",
"Other": "1. Log on to the Huawei Cloud console.\n2. Choose VPC.\n3. Click Security Groups.\n4. Select the security group.\n5. Edit each allow egress rule whose destination is 0.0.0.0/0, ::/0, or empty and unscoped.\n6. Restrict the destination to required IP ranges or an appropriate security group or address group.\n7. Click OK.",
"Terraform": ""
},
"Recommendation": {
"Text": "Restrict allow egress rules to required destination IP ranges, security groups, or address groups instead of allowing all IPv4 or IPv6 destinations.",
"Url": "https://hub.prowler.com/check/vpc_security_group_open_egress"
}
},
"Categories": [
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,56 @@
from prowler.lib.check.models import Check, CheckReportHuaweiCloud
from prowler.providers.huaweicloud.services.vpc.vpc_client import vpc_client
class vpc_security_group_open_egress(Check):
"""Check if VPC security groups allow unrestricted egress to the internet."""
def execute(self) -> list[CheckReportHuaweiCloud]:
"""Execute the unrestricted egress check.
Returns:
list[CheckReportHuaweiCloud]: Reports for the evaluated security groups.
"""
findings = []
for sg in vpc_client.security_groups.values():
report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=sg)
report.region = sg.region
report.resource_id = sg.id
report.resource_arn = (
f"huaweicloud:vpc:{sg.region}:"
f"{vpc_client.audited_account}:security-group/{sg.id}"
)
open_egress_destination = None
for rule in sg.rules:
if (
rule.direction != "egress"
or rule.action != "allow"
or rule.remote_group_id
or rule.remote_address_group_id
):
continue
if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"):
open_egress_destination = rule.remote_ip_prefix
break
if not rule.remote_ip_prefix:
open_egress_destination = "all destinations"
break
if open_egress_destination:
report.status = "FAIL"
report.status_extended = (
f"Security group {sg.name} ({sg.id}) allows open egress "
f"({open_egress_destination}) to the internet."
)
else:
report.status = "PASS"
report.status_extended = (
f"Security group {sg.name} ({sg.id}) does not allow open egress "
f"to the internet."
)
findings.append(report)
return findings
@@ -91,6 +91,8 @@ class VPC(HuaweiCloudService):
id=getattr(rule_data, "id", None) or "",
direction=getattr(rule_data, "direction", None)
or "",
action=getattr(rule_data, "action", None)
or "allow",
protocol=getattr(rule_data, "protocol", None)
or "",
ethertype=getattr(rule_data, "ethertype", None)
@@ -109,6 +111,12 @@ class VPC(HuaweiCloudService):
rule_data, "remote_group_id", None
)
or "",
remote_address_group_id=getattr(
rule_data,
"remote_address_group_id",
None,
)
or "",
description=getattr(
rule_data, "description", None
)
@@ -148,12 +156,14 @@ class SecurityGroupRule(HuaweiCloudBaseModel):
id: str
direction: str
action: str = "allow"
protocol: str
ethertype: str
port_range_min: Optional[int] = None
port_range_max: Optional[int] = None
remote_ip_prefix: str = ""
remote_group_id: str = ""
remote_address_group_id: str = ""
description: str = ""
@@ -181,13 +191,16 @@ def rule_source_is_open(rule: SecurityGroupRule) -> bool:
Huawei Cloud represents "any source" in two ways: an explicit ``0.0.0.0/0``
(or ``::/0``) in ``remote_ip_prefix``, or leaving both ``remote_ip_prefix``
and ``remote_group_id`` empty. Rules that reference another security group
via ``remote_group_id`` are NOT open even when ``remote_ip_prefix`` is
empty.
and both group identifiers empty. Rules that reference a security group or
address group are NOT open even when ``remote_ip_prefix`` is empty.
"""
if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"):
return True
return not rule.remote_ip_prefix and not rule.remote_group_id
return (
not rule.remote_ip_prefix
and not rule.remote_group_id
and not rule.remote_address_group_id
)
def rule_covers_all_ports(rule: SecurityGroupRule) -> bool:
@@ -0,0 +1,4 @@
from prowler.providers.common.provider import Provider
from prowler.providers.stackit.services.ske.ske_service import SKEService
ske_client = SKEService(Provider.get_global_provider())
@@ -0,0 +1,38 @@
{
"Provider": "stackit",
"CheckID": "ske_cluster_no_public_endpoint",
"CheckTitle": "SKE clusters do not expose their Kubernetes API endpoint to the internet",
"CheckType": [],
"ServiceName": "ske",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "container",
"Description": "The **Kubernetes API server** of an SKE cluster should not be reachable from the entire internet. Enable the **ACL extension** to restrict access to a known set of source CIDRs, or run the control plane inside a **STACKIT Network Area (SNA)** so that it is not published on the internet at all.",
"Risk": "A publicly reachable **Kubernetes API server** lets anyone on the internet attempt authentication, exploit unpatched control plane vulnerabilities, or brute-force credentials. Combined with leaked or weak credentials, this can lead to **full cluster takeover**, workload tampering, and data exfiltration.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/enhance-the-security-of-your-cluster/",
"https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/enable-private-clusters/",
"https://docs.stackit.cloud/products/runtime/kubernetes-engine/"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. In the STACKIT Portal open Kubernetes Engine (SKE) and select the affected cluster. 2. Open the cluster configuration and enable the ACL extension. 3. Add only the source CIDRs that must reach the Kubernetes API (for example your office or VPN egress ranges) and make sure 0.0.0.0/0 and ::/0 are not present. 4. Save the cluster configuration and re-run Prowler to confirm the finding is resolved.",
"Terraform": ""
},
"Recommendation": {
"Text": "**Restrict access to the Kubernetes API.** Enable the **SKE ACL extension** and allowlist only the source CIDRs that legitimately need to reach the API server, never `0.0.0.0/0` or `::/0`. Where available, place the control plane in a **STACKIT Network Area** so the endpoint is not exposed to the internet.",
"Url": "https://hub.prowler.com/check/ske_cluster_no_public_endpoint"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "A cluster passes when its control plane access scope is SNA, or when the ACL extension is enabled and its allowed CIDR list contains no unrestricted range. A cluster with the ACL extension disabled is reported as FAIL because the Kubernetes API accepts connections from any source address. The SNA private control plane is not generally available yet, so most clusters are expected to rely on the ACL extension."
}
@@ -0,0 +1,61 @@
from prowler.lib.check.models import Check, CheckReportStackIT
from prowler.providers.stackit.services.ske.ske_client import ske_client
class ske_cluster_no_public_endpoint(Check):
"""
Check if SKE clusters expose their Kubernetes API endpoint to the internet.
A cluster passes when its control plane is confined to a STACKIT Network
Area, or when the ACL extension restricts the Kubernetes API to a set of
source CIDRs that does not include an unrestricted range.
"""
def execute(self) -> list[CheckReportStackIT]:
"""
Execute the check for all SKE clusters in the StackIT project.
Returns:
list: A list of CheckReportStackIT findings
"""
findings = []
for cluster in ske_client.clusters:
report = CheckReportStackIT(
metadata=self.metadata(),
resource=cluster,
)
if cluster.has_public_endpoint():
report.status = "FAIL"
unrestricted_cidrs = cluster.unrestricted_cidrs()
if unrestricted_cidrs:
report.status_extended = (
f"SKE cluster {cluster.name} exposes its Kubernetes API "
f"endpoint to the internet because its ACL allows "
f"unrestricted access from {', '.join(unrestricted_cidrs)}."
)
else:
report.status_extended = (
f"SKE cluster {cluster.name} exposes its Kubernetes API "
f"endpoint to the internet because the ACL extension is "
f"not enabled."
)
else:
report.status = "PASS"
if cluster.has_private_control_plane():
report.status_extended = (
f"SKE cluster {cluster.name} has a private control plane "
f"and its Kubernetes API endpoint is not reachable from "
f"the internet."
)
else:
report.status_extended = (
f"SKE cluster {cluster.name} restricts access to its "
f"Kubernetes API endpoint to "
f"{len(cluster.allowed_cidrs)} allowed CIDR(s)."
)
findings.append(report)
return findings
@@ -0,0 +1,305 @@
import json
from typing import Optional
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr
# Source ranges that place no restriction on who may reach the Kubernetes API.
UNRESTRICTED_CIDRS = frozenset({"0.0.0.0/0", "::/0"})
# Control plane access scope that keeps the Kubernetes API inside a STACKIT
# Network Area instead of publishing it on the internet.
PRIVATE_ACCESS_SCOPE = "SNA"
class SKEService:
"""
StackIT Kubernetes Engine (SKE) service class to handle cluster operations.
This service uses the StackIT Python SDK to access SKE resources.
Authentication is delegated to the SDK, which signs the RSA challenge
in the configured service account key and refreshes access tokens
internally for the life of the scan.
"""
def __init__(self, provider: StackitProvider):
"""
Initialize the SKE service.
Args:
provider: The StackIT provider instance
"""
self.provider = provider
self.project_id = provider.identity.project_id
self.service_account_key_path = provider.session.get("service_account_key_path")
# Generate regional clients (AWS pattern)
self.regional_clients = provider.generate_regional_clients("ske")
self.audited_regions = provider.identity.audited_regions
# Initialize cluster list
self.clusters: list[Cluster] = []
# Fetch resources from all regions
self._fetch_all_regions()
def _fetch_all_regions(self):
"""Fetch SKE clusters from all audited regions.
A project is not necessarily provisioned in every StackIT region. A
region where the project does not exist answers the SKE endpoints with
HTTP 404 (``resource not found: project``). That is expected, so the
region is skipped and the scan continues with the remaining regions
instead of aborting.
A project that enabled SKE in some regions but not others answers the
remaining ones with HTTP 403 ``Service not enabled``; that region is
skipped too. Genuine credential and permission failures still propagate
via ``handle_api_error`` so a misconfigured account fails loudly.
"""
for region, client in self.regional_clients.items():
try:
self._list_clusters(client, region)
except Exception as error:
if getattr(error, "status", None) == 404:
logger.info(
f"StackIT project {self.project_id} has no SKE presence "
f"in region {region} (404 resource not found); skipping "
f"this region."
)
continue
if self._is_service_not_enabled(error):
logger.info(
f"StackIT project {self.project_id} has SKE disabled in "
f"region {region} (403 service not enabled); skipping "
f"this region."
)
continue
raise
@staticmethod
def _extract_items(response, endpoint_name: str) -> list:
"""Extract the items list from a StackIT SDK response.
Handles three response shapes safely:
- SDK model exposing an ``items`` attribute (not the ``dict.items`` method)
- Raw ``dict`` with an ``"items"`` key
- Plain ``list``
``isinstance(response, dict)`` is checked first because ``dict`` has an
``items`` *method*; ``hasattr(response, "items")`` is otherwise True for
plain dicts and silently returns the bound method.
"""
if isinstance(response, dict):
return response.get("items", [])
if isinstance(response, list):
return response
items_attr = getattr(response, "items", None)
if items_attr is not None and not callable(items_attr):
return items_attr
logger.warning(
f"Unexpected response type from {endpoint_name}: {type(response)}"
)
return []
@staticmethod
def _get_field(item, *keys, default=None):
"""Read a field from an SDK model (attribute) or a raw ``dict`` (key).
``_extract_items`` yields either SDK models or raw dicts, so the nested
cluster parsing must read fields from both shapes. Multiple key aliases
are accepted so snake_case SDK attributes and camelCase API/dict keys
are both supported (e.g. ``allowed_cidrs`` / ``allowedCidrs``). ``None``
items return ``default`` so nested lookups can be chained safely.
Returns the first non-None match, otherwise ``default``.
"""
if item is None:
return default
if isinstance(item, dict):
for key in keys:
value = item.get(key)
if value is not None:
return value
return default
for key in keys:
value = getattr(item, key, None)
if value is not None:
return value
return default
@staticmethod
def _is_service_not_enabled(error) -> bool:
"""Return True for the 403 SKE returns in a region that never enabled it.
Status alone cannot separate this from a missing IAM role -- both are
403 -- so the body message is the only discriminator.
"""
if getattr(error, "status", None) != 403:
return False
body = getattr(error, "body", None)
if body is None:
return False
if isinstance(body, bytes):
body = body.decode("utf-8", errors="replace")
message = ""
if isinstance(body, str):
try:
message = json.loads(body).get("message", "")
except (ValueError, AttributeError):
message = body
elif isinstance(body, dict):
message = body.get("message", "")
return "service not enabled" in str(message).lower()
def _handle_api_call(self, api_function, *args, **kwargs):
"""
Centralized API call handler with authentication error detection.
Args:
api_function: The API function to call
*args: Positional arguments to pass to the API function
**kwargs: Keyword arguments to pass to the API function
Returns:
The API response
Raises:
StackITInvalidTokenError: If authentication fails (401)
"""
try:
# Suppress StackIT SDK stderr messages during API calls
with suppress_stderr():
return api_function(*args, **kwargs)
except Exception as e:
# A region that never enabled SKE also answers 403; handing it to
# handle_api_error would abort the scan as a credentials failure
# instead of letting _fetch_all_regions skip the region.
if self._is_service_not_enabled(e):
raise
# Use centralized error handler from provider
self.provider.handle_api_error(e)
raise
@classmethod
def _parse_access_scope(cls, cluster_data) -> Optional[str]:
"""Return the control plane access scope of a cluster, if declared.
The scope lives at ``network.controlPlane.accessScope``. Every level of
that chain is optional because the private (``SNA``) control plane is an
opt-in preview feature, so most clusters omit it entirely. ``None`` is
returned in that case and the caller falls back to the ACL extension.
"""
network = cls._get_field(cluster_data, "network")
control_plane = cls._get_field(network, "control_plane", "controlPlane")
access_scope = cls._get_field(control_plane, "access_scope", "accessScope")
if access_scope is None:
return None
# ``AccessScope`` is a ``str`` Enum, whose ``str()`` renders as
# "AccessScope.SNA" rather than the "SNA" wire value.
return str(getattr(access_scope, "value", access_scope))
@classmethod
def _parse_acl(cls, cluster_data) -> tuple[bool, list[str]]:
"""Return the ``(enabled, allowed_cidrs)`` pair of the cluster ACL extension.
The ACL extension restricts which source CIDRs may reach the Kubernetes
API and lives at ``extensions.acl``. When the extension is absent the
API server accepts connections from any address, which is reported here
as ``(False, [])``.
"""
extensions = cls._get_field(cluster_data, "extensions")
acl = cls._get_field(extensions, "acl")
enabled = bool(cls._get_field(acl, "enabled", default=False))
allowed_cidrs = (
cls._get_field(acl, "allowed_cidrs", "allowedCidrs", default=[]) or []
)
return enabled, [str(cidr) for cidr in allowed_cidrs]
def _list_clusters(self, client, region: str):
"""
List all SKE clusters in the StackIT project for a single region.
Populates ``self.clusters`` with :class:`Cluster` objects describing the
control plane access scope and the ACL extension of each cluster.
"""
if not client:
logger.warning(
f"Cannot list SKE clusters in {region}: StackIT SKE client not available"
)
return
response = self._handle_api_call(
client.list_clusters, project_id=self.project_id, region=region
)
clusters_list = self._extract_items(response, "list_clusters")
for cluster_data in clusters_list:
try:
cluster_name = str(self._get_field(cluster_data, "name") or "")
acl_enabled, allowed_cidrs = self._parse_acl(cluster_data)
cluster = Cluster(
# SKE addresses a cluster by its name; the API exposes no
# separate identifier, so the name doubles as the id.
id=cluster_name,
name=cluster_name,
project_id=self.project_id,
region=region,
access_scope=self._parse_access_scope(cluster_data),
acl_enabled=acl_enabled,
allowed_cidrs=allowed_cidrs,
)
self.clusters.append(cluster)
except Exception as e:
logger.error(f"Error processing SKE cluster: {e}")
continue
logger.info(
f"Successfully listed {len(clusters_list)} SKE clusters in {region}"
)
class Cluster(BaseModel):
"""
Represents a StackIT SKE Cluster.
Attributes:
id: The unique identifier of the cluster (SKE uses the cluster name)
name: The name of the cluster
project_id: The StackIT project ID containing the cluster
region: The region where the cluster runs
access_scope: Control plane access scope ("PUBLIC"/"SNA"), None when unset
acl_enabled: Whether the ACL extension restricting API access is enabled
allowed_cidrs: Source CIDRs allowed to reach the Kubernetes API
"""
id: str
name: str
project_id: str
region: str
access_scope: Optional[str] = None
acl_enabled: bool = False
allowed_cidrs: list[str] = []
def has_private_control_plane(self) -> bool:
"""Check whether the control plane is confined to a STACKIT Network Area."""
return self.access_scope == PRIVATE_ACCESS_SCOPE
def unrestricted_cidrs(self) -> list[str]:
"""Return the allowed CIDRs that permit access from any source address."""
return [cidr for cidr in self.allowed_cidrs if cidr in UNRESTRICTED_CIDRS]
def has_public_endpoint(self) -> bool:
"""Check whether the Kubernetes API endpoint is reachable from the internet.
A cluster is publicly reachable when its control plane is not confined to
a STACKIT Network Area and either the ACL extension is disabled (no source
restriction at all) or the allowlist itself contains an unrestricted range.
"""
if self.has_private_control_plane():
return False
if not self.acl_enabled:
return True
return bool(self.unrestricted_cidrs())
+14 -4
View File
@@ -17,6 +17,7 @@ from stackit.core.configuration import Configuration
from stackit.iaas import DefaultApi as IaasDefaultApi
from stackit.objectstorage import DefaultApi as ObjectStorageDefaultApi
from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi
from stackit.ske import DefaultApi as SkeDefaultApi
from prowler.config.config import (
default_config_file_path,
@@ -228,6 +229,7 @@ class StackitProvider(Provider):
_SERVICE_API_CLASS = {
"iaas": IaasDefaultApi,
"objectstorage": ObjectStorageDefaultApi,
"ske": SkeDefaultApi,
}
def generate_regional_clients(self, service: str = "iaas") -> dict:
@@ -557,10 +559,11 @@ class StackitProvider(Provider):
"""
Test connection to StackIT by validating credentials.
This method validates the service account credentials and project ID
by making a Resource Manager ``get_project`` call. Pass either the
key file path or the inline key content; the SDK signs the RSA
challenge and mints a short-lived access token internally.
This method probes Resource Manager with a ``get_project`` call. A 403
response is non-fatal because service-specific permissions are checked
during discovery. Pass either the key file path or the inline key
content; the SDK signs the RSA challenge and mints a short-lived access
token internally.
Args:
project_id (str): StackIT project ID
@@ -600,6 +603,13 @@ class StackitProvider(Provider):
raise error
return Connection(error=error)
except Exception as test_error:
if getattr(test_error, "status", None) == 403:
logger.warning(
"StackIT test_connection: Resource Manager access could not be "
"verified (403). Service permissions will be checked during "
"discovery."
)
return Connection(is_connected=True)
try:
StackitProvider.handle_api_error(test_error)
except StackITInvalidTokenError as auth_error:
@@ -11,6 +11,12 @@
"eu01",
"eu02"
]
},
"ske": {
"regions": [
"eu01",
"eu02"
]
}
}
}
+1
View File
@@ -102,6 +102,7 @@ dependencies = [
"stackit-iaas==1.4.0",
"stackit-objectstorage==1.4.0",
"stackit-resourcemanager==0.8.0",
"stackit-ske==1.12.0",
"tabulate==0.9.0",
"tzlocal==5.3.1",
"uuid6==2024.7.10",
@@ -0,0 +1,633 @@
from unittest import mock
import botocore
from botocore.exceptions import ClientError
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
AWS_REGION_US_WEST_2,
set_mocked_aws_provider,
)
make_api_call = botocore.client.BaseClient._make_api_call
AGENT_A_ID = "test-agent-a"
AGENT_A_NAME = "agent-alpha"
AGENT_A_ARN = (
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_A_ID}"
)
AGENT_B_ID = "test-agent-b"
AGENT_B_NAME = "agent-bravo"
AGENT_B_ARN = (
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_B_ID}"
)
AGENT_C_ID = "test-agent-c"
AGENT_C_NAME = "agent-charlie"
SHARED_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/SharedAgentRole"
ROLE_A_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentAlphaRole"
ROLE_B_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentBravoRole"
# Operations the BedrockAgent constructor calls that these tests do not exercise.
_UNUSED_OPERATIONS = (
"ListPrompts",
"GetPrompt",
"ListTagsForResource",
"ListKnowledgeBases",
"ListDataSources",
"GetDataSource",
)
# These scenarios describe agents with no alias, so no version is deployed and
# the draft role is the only one in play. ListAgentAliases is stubbed empty
# rather than left to moto, which does not implement it: an unstubbed call would
# leave the version inventory unread and correctly downgrade every PASS to
# MANUAL, masking what these tests are actually asserting.
_NO_ALIASES = {"agentAliasSummaries": []}
def _agent_mock(agents, fail_get_for=()):
"""Build a _make_api_call replacement returning the given agents.
Args:
agents: list of (agentId, agentName, roleArn) tuples.
fail_get_for: agent ids whose GetAgent call must raise.
"""
def _mock(self, operation_name, kwarg):
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListAgentAliases":
return _NO_ALIASES
if operation_name == "ListAgents":
return {
"agentSummaries": [
{
"agentId": agent_id,
"agentName": agent_name,
"agentStatus": "PREPARED",
}
for agent_id, agent_name, _ in agents
]
}
if operation_name == "GetAgent":
agent_id = kwarg["agentId"]
if agent_id in fail_get_for:
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
# GetAgent nests its payload under a top-level agent key.
for candidate_id, agent_name, role_arn in agents:
if candidate_id == agent_id:
agent = {
"agentId": candidate_id,
"agentName": agent_name,
"agentStatus": "PREPARED",
}
if role_arn is not None:
agent["agentResourceRoleArn"] = role_arn
return {"agent": agent}
return {"agent": {}}
return make_api_call(self, operation_name, kwarg)
return _mock
_mock_dedicated_roles = _agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN),
(AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN),
]
)
_mock_shared_role = _agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
]
)
_mock_shared_role_three_agents = _agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
(AGENT_C_ID, AGENT_C_NAME, SHARED_ROLE_ARN),
]
)
_mock_single_agent = _agent_mock([(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN)])
_mock_role_missing = _agent_mock([(AGENT_A_ID, AGENT_A_NAME, None)])
_mock_get_agent_fails = _agent_mock(
[(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN)], fail_get_for=(AGENT_A_ID,)
)
# One agent's role is unreadable, so it must not count toward the other's share.
_mock_one_unreadable_one_readable = _agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
],
fail_get_for=(AGENT_B_ID,),
)
def _mock_empty(self, operation_name, kwarg):
"""No agents at all."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListAgentAliases":
return _NO_ALIASES
if operation_name == "ListAgents":
return {"agentSummaries": []}
return make_api_call(self, operation_name, kwarg)
def _mock_unsupported_region(self, operation_name, kwarg):
"""The API is not available in the audited region."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListAgentAliases":
return _NO_ALIASES
if operation_name == "ListAgents":
raise ClientError(
{
"Error": {
"Code": "ValidationException",
"Message": "Bedrock Agent is not supported in this region.",
}
},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
def _inventory_mock(agents):
"""Build a stub for one exhaustive-matrix inventory.
Args:
agents: list of (name, roleArn, retrieved) tuples. retrieved=False makes
GetAgent raise for that agent.
"""
rows = [(name, name, role) for name, role, _ in agents]
fail = tuple(name for name, _, retrieved in agents if not retrieved)
return _agent_mock(rows, fail_get_for=fail)
def _mock_list_agents_denied(self, operation_name, kwarg):
"""ListAgents is denied, so the region's agents are unknown."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListAgentAliases":
return _NO_ALIASES
if operation_name == "ListAgents":
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
def _mock_dedicated_role_with_partial_inventory(self, operation_name, kwarg):
"""us-east-1 lists one agent; us-west-2's ListAgents is denied.
The listed agent's role is used by no other KNOWN agent, but an unlisted
Region could hold one sharing it, so PASS must not be asserted.
"""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListAgentAliases":
return _NO_ALIASES
region = self.meta.region_name
if operation_name == "ListAgents":
if region == AWS_REGION_US_EAST_1:
return {
"agentSummaries": [
{
"agentId": AGENT_A_ID,
"agentName": AGENT_A_NAME,
"agentStatus": "PREPARED",
}
]
}
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
if operation_name == "GetAgent":
return {
"agent": {
"agentId": AGENT_A_ID,
"agentName": AGENT_A_NAME,
"agentStatus": "PREPARED",
"agentResourceRoleArn": ROLE_A_ARN,
}
}
return make_api_call(self, operation_name, kwarg)
class Test_bedrock_agent_role_not_shared_across_agents:
"""Unit tests for the bedrock_agent_role_not_shared_across_agents check."""
def _run(self):
"""Import the service + check under the active mocks and execute."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider([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.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import (
bedrock_agent_role_not_shared_across_agents,
)
return bedrock_agent_role_not_shared_across_agents().execute()
def _run_multi_region(self):
"""Same as _run but with two Regions in scope."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1, AWS_REGION_US_WEST_2]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import (
bedrock_agent_role_not_shared_across_agents,
)
return bedrock_agent_role_not_shared_across_agents().execute()
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
@mock_aws
def test_no_resources(self):
"""No resources means no findings, not a spurious FAIL."""
assert self._run() == []
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
)
@mock_aws
def test_region_not_supported(self):
"""A ValidationException from the region must not raise; it yields no findings."""
assert self._run() == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_single_agent)
@mock_aws
def test_single_agent_passes(self):
"""The only agent in the account cannot be sharing its role."""
result = self._run()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == AGENT_A_ID
assert result[0].resource_arn == AGENT_A_ARN
assert result[0].region == AWS_REGION_US_EAST_1
assert (
result[0].status_extended
== f"Bedrock Agent {AGENT_A_NAME} has a dedicated execution role in region {AWS_REGION_US_EAST_1}."
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_dedicated_roles)
@mock_aws
def test_distinct_roles_pass(self):
"""Two agents with distinct roles are both compliant."""
result = self._run()
assert len(result) == 2
assert {report.status for report in result} == {"PASS"}
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_shared_role)
@mock_aws
def test_shared_role_fails_for_both_agents(self):
"""A role used by two agents fails for each of them, naming the other."""
result = self._run()
assert len(result) == 2
assert {report.status for report in result} == {"FAIL"}
by_id = {report.resource_id: report for report in result}
assert SHARED_ROLE_ARN in by_id[AGENT_A_ID].status_extended
assert AGENT_B_NAME in by_id[AGENT_A_ID].status_extended
assert AGENT_A_NAME in by_id[AGENT_B_ID].status_extended
assert "cannot attribute an action" in by_id[AGENT_A_ID].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_shared_role_three_agents
)
@mock_aws
def test_shared_role_names_others_sorted(self):
"""The other sharing agents are listed in sorted order for determinism."""
result = self._run()
assert len(result) == 3
assert {report.status for report in result} == {"FAIL"}
by_id = {report.resource_id: report for report in result}
assert (
f"with {AGENT_B_NAME}, {AGENT_C_NAME} in region"
in by_id[AGENT_A_ID].status_extended
)
assert (
f"with {AGENT_A_NAME}, {AGENT_C_NAME} in region"
in by_id[AGENT_B_ID].status_extended
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_role_missing)
@mock_aws
def test_role_absent_is_manual_not_pass(self):
"""An agent whose GetAgent returned no role must not be reported as compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be retrieved" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_get_agent_fails)
@mock_aws
def test_get_agent_failure_is_manual_not_pass(self):
"""A failed GetAgent must not be reported as compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be retrieved" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_mock_one_unreadable_one_readable,
)
@mock_aws
def test_unreadable_agent_does_not_inflate_share_count(self):
"""An excluded agent must neither manufacture a FAIL nor allow a PASS.
The unreadable agent is kept out of the share index, so the readable agent
is not reported as sharing. But its role is unknown and could be the same
one, so the readable agent cannot be asserted dedicated either: both are
MANUAL, and neither is FAIL.
"""
result = self._run()
assert len(result) == 2
by_id = {report.resource_id: report for report in result}
assert by_id[AGENT_B_ID].status == "MANUAL"
assert by_id[AGENT_A_ID].status == "MANUAL"
assert "could not be read" in by_id[AGENT_A_ID].status_extended
# The entry names what could not be read, so the sentence stays
# grammatical for a role, a version set, or a Region alike.
assert (
f"the execution role of {AGENT_B_NAME}" in by_id[AGENT_A_ID].status_extended
)
assert {report.status for report in result} == {"MANUAL"}
def test_every_inventory_shape_resolves_correctly(self):
"""Exhaust the decision space instead of sampling it.
Bedrock Agents cannot be created in every account (the service refuses new
agents for accounts without prior usage), so this check's behaviour is
pinned by enumerating every inventory of up to three agents over the cross
product of {role A, role B, no role} x {readable, unreadable}, and
asserting the verdict for each agent. A role counts as shared only when two
or more READABLE agents hold it.
Each case is driven through the real BedrockAgent service so a renamed
service attribute breaks the test rather than passing silently.
"""
from itertools import product
states = [
(ROLE_A_ARN, True),
(ROLE_B_ARN, True),
(None, True),
(ROLE_A_ARN, False),
]
checked = 0
for size in (1, 2, 3):
for combo in product(states, repeat=size):
agents = [
(f"agent-{index}", role, retrieved)
for index, (role, retrieved) in enumerate(combo)
]
readable_per_role = {}
for _, role, retrieved in agents:
if retrieved and role:
readable_per_role[role] = readable_per_role.get(role, 0) + 1
# Any agent whose own role could not be read leaves the picture
# incomplete, so no other agent can be asserted dedicated. A role
# already seen twice is shared regardless.
any_unresolved = any(
not retrieved or not role for _, role, retrieved in agents
)
expected = sorted(
(
"MANUAL"
if not retrieved or not role
else (
"FAIL"
if readable_per_role[role] >= 2
else ("MANUAL" if any_unresolved else "PASS")
)
)
for _, role, retrieved in agents
)
with mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_inventory_mock(agents),
):
with mock_aws():
result = self._run()
assert len(result) == size, combo
assert sorted(report.status for report in result) == expected, combo
assert all(
report.status_extended.endswith(".") for report in result
), combo
checked += 1
# 4 + 16 + 64 inventories.
assert checked == 84
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_list_agents_denied
)
@mock_aws
def test_list_agents_denied_is_manual_not_silence(self):
"""A denied ListAgents must report MANUAL for the region, not vanish."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "agent/unknown"
assert "could not be listed" in result[0].status_extended
assert "AccessDeniedException" in result[0].status_extended
assert result[0].status_extended.endswith(".")
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_mock_dedicated_role_with_partial_inventory,
)
@mock_aws
def test_dedicated_role_is_manual_when_inventory_incomplete(self):
"""A seemingly dedicated role cannot be asserted from a partial inventory.
One Region lists an agent whose role no other known agent uses, while
another Region's ListAgents fails. An unlisted Region could hold an agent
sharing that role, so the verdict is MANUAL rather than PASS.
"""
result = self._run_multi_region()
by_status = {}
for report in result:
by_status.setdefault(report.status, []).append(report)
assert "PASS" not in by_status, [r.status_extended for r in result]
assert len(by_status["MANUAL"]) == 2
agent_report = [
r for r in by_status["MANUAL"] if r.resource_id != "agent/unknown"
]
assert len(agent_report) == 1
assert "agents in region" in agent_report[0].status_extended
assert all(r.status_extended.endswith(".") for r in result)
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN),
(AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN),
],
fail_get_for=(AGENT_B_ID,),
),
)
@mock_aws
def test_unresolved_role_blocks_pass_for_a_distinct_role(self):
"""One agent's unreadable role prevents asserting another's dedication.
Agent A holds a role no other *readable* agent uses, so the old logic
returned PASS. Agent B's role could not be retrieved and may be the same
one, so PASS would be an assertion the data does not support.
"""
result = self._run()
assert len(result) == 2
by_id = {report.resource_id: report for report in result}
assert by_id[AGENT_B_ID].status == "MANUAL"
assert by_id[AGENT_A_ID].status == "MANUAL"
assert AGENT_B_NAME in by_id[AGENT_A_ID].status_extended
assert "PASS" not in {report.status for report in result}
class Test_scoped_scan_still_sees_the_sharing:
"""A --resource-arn scoped scan must not turn a shared role into a PASS.
Collectors apply is_resource_filtered at COLLECTION time, so
bedrock_agent_client.agents holds only the agents the operator selected.
Whether a role is shared is a property of every agent that holds it, so an
index built from the filtered set cannot see the agent that proves the
violation -- and an operator filter is not a scan error, so nothing marks the
inventory incomplete either. The check therefore aggregates over all_agents
and filters only when emitting findings.
"""
def _run_scoped(self, audit_resources):
"""Execute the check with a scan scoped to the given resource ARNs."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
aws_provider._audit_resources = audit_resources
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
service = BedrockAgent(aws_provider)
with mock.patch(
"prowler.providers.aws.services.bedrock."
"bedrock_agent_role_not_shared_across_agents."
"bedrock_agent_role_not_shared_across_agents.bedrock_agent_client",
new=service,
):
from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import (
bedrock_agent_role_not_shared_across_agents,
)
return service, bedrock_agent_role_not_shared_across_agents().execute()
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
]
),
)
def test_selecting_one_of_two_sharing_agents_still_fails(self):
"""The unselected agent is what proves the role is shared.
Only agent A is in scope, so exactly one finding is emitted -- but agent
B, filtered out of the report, still holds the same role, so agent A's
role is not dedicated. Reporting PASS here was the reproducible false
PASS a scoped scan produced.
"""
service, result = self._run_scoped([AGENT_A_ARN])
# The report set is narrowed; the role index is not.
assert list(service.agents) == [AGENT_A_ARN]
assert sorted(service.all_agents) == sorted([AGENT_A_ARN, AGENT_B_ARN])
assert len(result) == 1
assert result[0].resource_id == AGENT_A_ID
assert result[0].resource_arn == AGENT_A_ARN
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].status == "FAIL"
assert SHARED_ROLE_ARN in result[0].status_extended
# The out-of-scope agent is still named, because it is the evidence.
assert AGENT_B_NAME in result[0].status_extended
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN),
(AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN),
]
),
)
def test_scoped_scan_on_a_genuinely_dedicated_role_passes(self):
"""Completeness must not manufacture a FAIL either.
Aggregating over the whole account is only correct if a genuinely
dedicated role still passes when the scan is scoped.
"""
service, result = self._run_scoped([AGENT_A_ARN])
assert list(service.agents) == [AGENT_A_ARN]
assert len(service.all_agents) == 2
assert len(result) == 1
assert result[0].resource_id == AGENT_A_ID
assert result[0].status == "PASS"
assert "has a dedicated execution role" in result[0].status_extended
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_agent_mock(
[
(AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN),
(AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN),
]
),
)
def test_unscoped_scan_is_unchanged(self):
"""With no filter, every agent is both aggregated and reported."""
service, result = self._run_scoped(None)
assert sorted(service.agents) == sorted(service.all_agents)
assert len(result) == 2
assert {report.status for report in result} == {"FAIL"}
@@ -0,0 +1,556 @@
"""Tests that a role shared through a deployed agent version is still reported.
GetAgent returns only the working draft. An agent version is an immutable
snapshot that keeps the role it was cut with, and an alias routes invocations at
a specific version, so two agents whose drafts hold distinct roles can still be
invoking one shared role in production. Judging the draft alone reports that as
compliant.
"""
from unittest import mock
import botocore
import pytest
from botocore.exceptions import ClientError
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
make_api_call = botocore.client.BaseClient._make_api_call
AGENT_A_ID = "test-agent-a"
AGENT_A_NAME = "agent-alpha"
AGENT_B_ID = "test-agent-b"
AGENT_B_NAME = "agent-bravo"
DRAFT_A_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentAlphaDraftRole"
DRAFT_B_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentBravoDraftRole"
SHARED_VERSION_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/SharedVersionRole"
DEDICATED_VERSION_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AlphaVersionRole"
# Collectors the BedrockAgent constructor runs that these tests do not exercise.
_UNUSED_OPERATIONS = (
"ListPrompts",
"GetPrompt",
"ListTagsForResource",
"ListKnowledgeBases",
"ListDataSources",
"GetDataSource",
)
DRAFT_ROLES = {AGENT_A_ID: DRAFT_A_ARN, AGENT_B_ID: DRAFT_B_ARN}
def _mock(
version_roles,
routed_version="3",
fail_list_aliases=(),
fail_get_version=(),
route_draft=False,
no_aliases=(),
alias_status="PREPARED",
alias_invocation_state=None,
aliases=None,
draft_roles=DRAFT_ROLES,
):
"""Build a _make_api_call replacement for a two-agent account.
Args:
version_roles: agent id -> role ARN its deployed version was cut with.
routed_version: the version each alias routes to.
fail_list_aliases: agent ids whose ListAgentAliases must raise.
fail_get_version: agent ids whose GetAgentVersion must raise.
route_draft: route the alias at DRAFT instead of a numbered version.
no_aliases: agent ids that have no alias at all.
alias_status: agentAliasStatus each alias reports.
alias_invocation_state: aliasInvocationState each alias reports; omitted
from the response entirely when None, which is what the API does for
an alias never set to reject.
aliases: agentAliasSummaries to return instead of building one alias.
draft_roles: agent id -> role ARN returned by GetAgent.
"""
def _call(self, operation_name, kwarg):
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListAgents":
return {
"agentSummaries": [
{
"agentId": AGENT_A_ID,
"agentName": AGENT_A_NAME,
"agentStatus": "PREPARED",
},
{
"agentId": AGENT_B_ID,
"agentName": AGENT_B_NAME,
"agentStatus": "PREPARED",
},
]
}
if operation_name == "GetAgent":
agent_id = kwarg["agentId"]
return {
"agent": {
"agentId": agent_id,
"agentName": agent_id,
"agentStatus": "PREPARED",
"agentResourceRoleArn": draft_roles[agent_id],
}
}
if operation_name == "ListAgentAliases":
agent_id = kwarg["agentId"]
if agent_id in fail_list_aliases:
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
if aliases is not None:
return {"agentAliasSummaries": aliases}
if agent_id in no_aliases:
return {"agentAliasSummaries": []}
alias = {
"agentAliasId": "alias-1",
"agentAliasName": "production",
"agentAliasStatus": alias_status,
"routingConfiguration": [
{"agentVersion": "DRAFT" if route_draft else routed_version}
],
}
if alias_invocation_state is not None:
alias["aliasInvocationState"] = alias_invocation_state
return {"agentAliasSummaries": [alias]}
if operation_name == "GetAgentVersion":
agent_id = kwarg["agentId"]
if agent_id in fail_get_version:
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return {
"agentVersion": {
"agentId": agent_id,
"agentName": agent_id,
"version": kwarg["agentVersion"],
"agentStatus": "PREPARED",
"agentResourceRoleArn": version_roles[agent_id],
}
}
return make_api_call(self, operation_name, kwarg)
return _call
def _run(stub):
"""Import the service and check under the stub and execute."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
check_name = "bedrock_agent_role_not_shared_across_agents"
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch("botocore.client.BaseClient._make_api_call", new=stub),
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
):
service = BedrockAgent(aws_provider)
with mock.patch(
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}.bedrock_agent_client",
new=service,
):
module = __import__(
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}",
fromlist=[check_name],
)
return service, getattr(module, check_name)().execute()
class Test_agent_version_roles:
"""Tests for sharing judged across the draft and every routed version."""
@mock_aws
def test_shared_version_role_fails_despite_distinct_drafts(self):
"""Distinct drafts do not make two agents dedicated.
Both agents' aliases route at a version cut with one shared role, so
both are invoking it in production. Judging the draft alone would call
this compliant.
"""
service, results = _run(
_mock({AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN})
)
for agent in service.agents.values():
assert agent.versions_listed is True
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
assert len(results) == 2
assert {report.status for report in results} == {"FAIL"}
for report in results:
assert SHARED_VERSION_ARN in report.status_extended
# The message names the version, so the reader knows the draft is
# not where the sharing lives.
assert "through deployed version 3" in report.status_extended
assert report.status_extended.endswith(".")
@mock_aws
def test_distinct_version_roles_pass(self):
"""Distinct roles on both the draft and the deployed version comply."""
_, results = _run(
_mock(
{
AGENT_A_ID: DEDICATED_VERSION_ARN,
AGENT_B_ID: f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/BravoVersionRole",
}
)
)
assert len(results) == 2
assert {report.status for report in results} == {"PASS"}
@mock_aws
def test_draft_sharing_still_names_no_version(self):
"""Sharing on the draft is reported without a version attribution."""
stub = _mock(
{
AGENT_A_ID: DEDICATED_VERSION_ARN,
AGENT_B_ID: f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/BravoVersionRole",
}
)
def _shared_draft(self, operation_name, kwarg):
if operation_name == "GetAgent":
return {
"agent": {
"agentId": kwarg["agentId"],
"agentName": kwarg["agentId"],
"agentStatus": "PREPARED",
"agentResourceRoleArn": DRAFT_A_ARN,
}
}
return stub(self, operation_name, kwarg)
_, results = _run(_shared_draft)
assert len(results) == 2
assert {report.status for report in results} == {"FAIL"}
for report in results:
assert DRAFT_A_ARN in report.status_extended
assert "through deployed version" not in report.status_extended
@mock_aws
def test_alias_routing_at_draft_needs_no_version_call(self):
"""DRAFT resolves to the role GetAgent already returned."""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
route_draft=True,
)
)
for agent in service.agents.values():
assert agent.versions_listed is True
assert agent.version_role_arns == {}
assert len(results) == 2
assert {report.status for report in results} == {"PASS"}
@mock_aws
def test_agent_without_an_alias_deploys_no_version(self):
"""No alias means no version is reachable, so the draft is the verdict."""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
no_aliases=(AGENT_A_ID, AGENT_B_ID),
)
)
for agent in service.agents.values():
assert agent.versions_listed is True
assert agent.version_role_arns == {}
assert {report.status for report in results} == {"PASS"}
@mock_aws
def test_unlistable_aliases_block_pass(self):
"""An unread version inventory may hold the same role, so PASS is unsafe."""
service, results = _run(
_mock(
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
fail_list_aliases=(AGENT_B_ID,),
)
)
agent_b = next(a for a in service.agents.values() if a.id == AGENT_B_ID)
assert agent_b.versions_listed is False
assert agent_b.versions_error == "AccessDeniedException"
assert "PASS" not in {report.status for report in results}
assert {report.status for report in results} == {"MANUAL"}
assert any(
"deployed versions of" in report.status_extended for report in results
)
@mock_aws
def test_unreadable_version_blocks_pass(self):
"""A failed GetAgentVersion leaves that version's role unknown."""
service, results = _run(
_mock(
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
fail_get_version=(AGENT_A_ID,),
)
)
agent_a = next(a for a in service.agents.values() if a.id == AGENT_A_ID)
assert agent_a.versions_listed is False
assert "PASS" not in {report.status for report in results}
@mock_aws
def test_shared_version_role_outranks_an_incomplete_inventory(self):
"""A role seen on two agents is shared whatever else is missing.
Both agents route at two versions and the second version is unreadable,
so the inventory is incomplete and the read half already shows the role
on both. A partial answer must not downgrade a definite finding, so FAIL
stands rather than MANUAL.
"""
def _two_versions_one_unreadable(self, operation_name, kwarg):
if operation_name == "ListAgentAliases":
return {
"agentAliasSummaries": [
{
"agentAliasId": "alias-1",
"agentAliasName": "production",
"agentAliasStatus": "PREPARED",
"routingConfiguration": [
{"agentVersion": "2"},
{"agentVersion": "3"},
],
}
]
}
if operation_name == "GetAgentVersion" and kwarg["agentVersion"] == "3":
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return _mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}
)(self, operation_name, kwarg)
service, results = _run(_two_versions_one_unreadable)
for agent in service.agents.values():
assert agent.versions_listed is False, "the inventory must be incomplete"
assert agent.version_role_arns == {"2": SHARED_VERSION_ARN}
assert {report.status for report in results} == {"FAIL"}
for report in results:
assert "through deployed version 2" in report.status_extended
class Test_alias_must_be_invocable:
"""A version only an unreachable alias routes to is not live exposure.
Widening the audit from the working draft to every routed version closes a
false PASS and opens the symmetric false FAIL: an alias that cannot invoke
the version it points at contributes no exposure, so the role on that
version must not count as shared. Both state fields come from
ListAgentAliases -- `aliasInvocationState` is ACCEPT_INVOCATIONS |
REJECT_INVOCATIONS, `agentAliasStatus` is CREATING | PREPARED | FAILED |
UPDATING | DELETING | DISSOCIATED.
"""
@mock_aws
def test_reject_invocations_alias_does_not_share_its_version_role(self):
"""An alias set to REJECT_INVOCATIONS cannot invoke the routed version.
Both agents' aliases route at a version cut with one shared role, so
judging routing alone reports FAIL -- but neither alias will accept an
invocation, so nothing is running under that role.
"""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
alias_invocation_state="REJECT_INVOCATIONS",
)
)
for agent in service.all_agents.values():
assert agent.versions_listed is True, "the aliases WERE listed"
assert agent.version_role_arns == {}, "a rejecting alias routes nothing"
assert len(results) == 2
assert {report.status for report in results} == {"PASS"}
for report in results:
assert "has a dedicated execution role" in report.status_extended
@pytest.mark.parametrize("alias_status", ["FAILED", "DELETING", "DISSOCIATED"])
@mock_aws
def test_terminal_alias_status_does_not_share_its_version_role(self, alias_status):
"""A failed, deleting or dissociated alias routes no live invocation."""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
alias_status=alias_status,
)
)
for agent in service.all_agents.values():
assert agent.version_role_arns == {}
assert len(results) == 2
assert {report.status for report in results} == {"PASS"}
@pytest.mark.parametrize("alias_status", ["CREATING", "UPDATING"])
@mock_aws
def test_in_flight_alias_status_makes_version_inventory_incomplete(
self, alias_status
):
"""CREATING and UPDATING do not prove that their version is invocable."""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
alias_status=alias_status,
)
)
for agent in service.all_agents.values():
assert agent.versions_listed is False
assert agent.version_role_arns == {}
assert len(results) == 2
assert {report.status for report in results} == {"MANUAL"}
for report in results:
assert "deployed versions of" in report.status_extended
@pytest.mark.parametrize("alias_status", ["CREATING", "UPDATING"])
@mock_aws
def test_shared_draft_role_fails_despite_in_flight_alias(self, alias_status):
"""Definite draft sharing outranks an incomplete alias inventory."""
service, results = _run(
_mock(
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
alias_status=alias_status,
draft_roles={AGENT_A_ID: DRAFT_A_ARN, AGENT_B_ID: DRAFT_A_ARN},
)
)
assert all(
agent.versions_listed is False for agent in service.all_agents.values()
)
assert {report.status for report in results} == {"FAIL"}
assert all(DRAFT_A_ARN in report.status_extended for report in results)
@mock_aws
def test_prepared_accepting_alias_shares_its_version_role(self):
"""A PREPARED alias accepting invocations is active."""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
alias_status="PREPARED",
alias_invocation_state="ACCEPT_INVOCATIONS",
)
)
for agent in service.all_agents.values():
assert agent.versions_listed is True
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
assert {report.status for report in results} == {"FAIL"}
@mock_aws
def test_prepared_alias_sharing_fails_despite_in_flight_alias(self):
"""Definite prepared-version sharing outranks incomplete inventory."""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
aliases=[
{
"agentAliasId": "alias-prepared",
"agentAliasName": "production",
"agentAliasStatus": "PREPARED",
"routingConfiguration": [{"agentVersion": "3"}],
},
{
"agentAliasId": "alias-updating",
"agentAliasName": "next",
"agentAliasStatus": "UPDATING",
"routingConfiguration": [{"agentVersion": "4"}],
},
],
)
)
for agent in service.all_agents.values():
assert agent.versions_listed is False
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
assert {report.status for report in results} == {"FAIL"}
assert all("through deployed version 3" in r.status_extended for r in results)
@mock_aws
def test_absent_invocation_state_still_shares_its_version_role(self):
"""aliasInvocationState is optional; absent means never set to reject.
Reading absence as "not accepting" would report every alias that was
never explicitly enabled as dead, and a genuinely shared role as PASS.
"""
service, results = _run(
_mock(
{AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN},
alias_invocation_state=None,
)
)
for agent in service.all_agents.values():
assert agent.version_role_arns == {"3": SHARED_VERSION_ARN}
assert {report.status for report in results} == {"FAIL"}
@mock_aws
def test_rejecting_alias_does_not_mask_a_shared_draft_role(self):
"""The predicate must gate ROUTING only, never the draft.
The draft role comes from GetAgent, not from an alias, so an unreachable
alias has no bearing on it. Without this, a predicate applied one level
too high would silence a genuinely shared draft role.
"""
service, results = _run(
_mock(
{AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN},
alias_invocation_state="REJECT_INVOCATIONS",
)
)
# No routed version survives the predicate...
for agent in service.all_agents.values():
assert agent.version_role_arns == {}
# ...so make both DRAFTS share one role and re-run the verdict.
for agent in service.all_agents.values():
agent.role_arn = DRAFT_A_ARN
check_name = "bedrock_agent_role_not_shared_across_agents"
with mock.patch(
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}"
".bedrock_agent_client",
new=service,
):
module = __import__(
f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}",
fromlist=[check_name],
)
results = getattr(module, check_name)().execute()
assert len(results) == 2
assert {report.status for report in results} == {"FAIL"}
for report in results:
assert DRAFT_A_ARN in report.status_extended
# The sharing is on the draft, so no version is named.
assert "through deployed version" not in report.status_extended
@@ -0,0 +1,287 @@
from unittest import mock
import botocore
from botocore.exceptions import ClientError
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
make_api_call = botocore.client.BaseClient._make_api_call
MODEL_NAME = "test-custom-model"
MODEL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:custom-model/example.base-v1/{MODEL_NAME}"
KMS_KEY_ARN = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/test-key-id"
FOREIGN_ACCOUNT = "999999999999"
FOREIGN_MODEL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{FOREIGN_ACCOUNT}:custom-model/example.base-v1/foreign-model"
# Operations the Bedrock constructor calls that these tests do not exercise.
_UNUSED_OPERATIONS = (
"GetModelInvocationLoggingConfiguration",
"ListGuardrails",
"GetGuardrail",
"ListTagsForResource",
)
def _custom_model_mock(kms_key_arn=None, fail_get=False):
"""Build a _make_api_call replacement returning one custom model."""
def _mock(self, operation_name, kwarg):
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListCustomModels":
return {
"modelSummaries": [
{"modelArn": MODEL_ARN, "modelName": MODEL_NAME},
]
}
if operation_name == "GetCustomModel":
if fail_get:
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
response = {"modelArn": MODEL_ARN, "modelName": MODEL_NAME}
if kms_key_arn is not None:
response["modelKmsKeyArn"] = kms_key_arn
return response
return make_api_call(self, operation_name, kwarg)
return _mock
_mock_with_cmk = _custom_model_mock(KMS_KEY_ARN)
_mock_without_cmk = _custom_model_mock(None)
_mock_empty_cmk = _custom_model_mock("")
_mock_unreadable = _custom_model_mock(fail_get=True)
def _mock_empty(self, operation_name, kwarg):
"""No custom models at all."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListCustomModels":
return {"modelSummaries": []}
return make_api_call(self, operation_name, kwarg)
def _mock_unsupported_region(self, operation_name, kwarg):
"""The API is not available in the audited region."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListCustomModels":
raise ClientError(
{
"Error": {
"Code": "ValidationException",
"Message": "Bedrock is not supported in this region.",
}
},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
def _mock_list_denied(self, operation_name, kwarg):
"""ListCustomModels is denied, so the region's models are unknown."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListCustomModels":
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
def _mock_shared_in_model(self, operation_name, kwarg):
"""Assert isOwned=True is sent, and return a model only when it is.
A caller that omits isOwned would also receive models shared into this
account through RAM, whose KMS key this account cannot set.
"""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListCustomModels":
if kwarg.get("isOwned") is not True:
# What the API would return without the filter: a foreign-owned
# model this account cannot remediate.
return {
"modelSummaries": [
{
"modelArn": FOREIGN_MODEL_ARN,
"modelName": "foreign-model",
"ownerAccountId": FOREIGN_ACCOUNT,
}
]
}
return {"modelSummaries": []}
return make_api_call(self, operation_name, kwarg)
class Test_bedrock_custom_model_encrypted_with_cmk:
"""Unit tests for the bedrock_custom_model_encrypted_with_cmk check."""
def _run(self):
"""Import the service + check under the active mocks and execute."""
from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
aws_provider = set_mocked_aws_provider([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.bedrock.bedrock_custom_model_encrypted_with_cmk.bedrock_custom_model_encrypted_with_cmk.bedrock_client",
new=Bedrock(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_custom_model_encrypted_with_cmk.bedrock_custom_model_encrypted_with_cmk import (
bedrock_custom_model_encrypted_with_cmk,
)
return bedrock_custom_model_encrypted_with_cmk().execute()
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
@mock_aws
def test_no_resources(self):
"""No resources means no findings, not a spurious FAIL."""
assert self._run() == []
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
)
@mock_aws
def test_region_not_supported(self):
"""A ValidationException from the region must not raise; it yields no findings."""
assert self._run() == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_with_cmk)
@mock_aws
def test_cmk_present_passes(self):
"""A model with modelKmsKeyArn set is compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == MODEL_NAME
assert result[0].resource_arn == MODEL_ARN
assert result[0].region == AWS_REGION_US_EAST_1
assert (
result[0].status_extended
== f"Bedrock custom model {MODEL_NAME} is encrypted with a customer-managed KMS key in region {AWS_REGION_US_EAST_1}."
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk)
@mock_aws
def test_no_cmk_fails(self):
"""An absent modelKmsKeyArn means an AWS-owned key is in use."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "is not encrypted with a customer-managed KMS key" in (
result[0].status_extended
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_cmk)
@mock_aws
def test_empty_cmk_fails(self):
"""An empty modelKmsKeyArn string is not a key."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "is not encrypted with a customer-managed KMS key" in (
result[0].status_extended
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable)
@mock_aws
def test_detail_unreadable_is_manual_not_pass(self):
"""A failed GetCustomModel must not be reported as compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be retrieved" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_denied)
@mock_aws
def test_list_denied_is_manual_not_silence(self):
"""A denied ListCustomModels must report MANUAL, not vanish.
Without a region-level report the region is indistinguishable from one
that genuinely holds no custom models.
"""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "custom-model/unknown"
assert (
result[0].resource_arn
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:custom-model/unknown"
)
assert "could not be listed" in result[0].status_extended
assert "AccessDeniedException" in result[0].status_extended
assert result[0].status_extended.endswith(".")
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_shared_in_model)
@mock_aws
def test_shared_in_models_are_not_audited(self):
"""ListCustomModels must be called with isOwned=True.
The mock returns a foreign-owned model only when the filter is absent, so
a regression that drops isOwned produces a FAIL naming another account's
model — a finding this account cannot remediate.
"""
result = self._run()
assert result == []
def test_every_model_state_resolves_correctly(self):
"""Exhaust the decision space instead of sampling it.
A Bedrock custom model cannot be created without a model customization
job, so this check's behaviour is pinned by enumerating every combination
of retrieval outcome, key value (absent, empty, set) and regional listing
outcome, and asserting the per-model verdict plus the presence of the
region-level report.
Each case is driven through the real Bedrock service so a renamed service
attribute breaks the test rather than passing silently.
"""
from itertools import product
for key, fail_get, list_denied in product(
[None, "", KMS_KEY_ARN], [False, True], [False, True]
):
case = (key, fail_get, list_denied)
stub = (
_mock_list_denied
if list_denied
else _custom_model_mock(key, fail_get=fail_get)
)
with mock.patch("botocore.client.BaseClient._make_api_call", new=stub):
with mock_aws():
result = self._run()
per_model = [
r for r in result if "custom-model/unknown" not in r.resource_arn
]
region_level = [
r for r in result if "custom-model/unknown" in r.resource_arn
]
if list_denied:
# No model reaches the inventory; only the region-level report.
assert per_model == [], case
assert len(region_level) == 1, case
assert region_level[0].status == "MANUAL", case
else:
assert len(per_model) == 1, case
expected = "MANUAL" if fail_get else ("PASS" if key else "FAIL")
assert per_model[0].status == expected, case
assert region_level == [], case
assert all(r.status_extended.endswith(".") for r in result), case
@@ -0,0 +1,466 @@
from unittest import mock
import botocore
from botocore.exceptions import ClientError
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
make_api_call = botocore.client.BaseClient._make_api_call
GUARDRAIL_ID = "test-guardrail-id"
GUARDRAIL_NAME = "test-guardrail"
GUARDRAIL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/{GUARDRAIL_ID}"
# Operations the Bedrock constructor calls that these tests do not exercise.
_UNUSED_OPERATIONS = (
"GetModelInvocationLoggingConfiguration",
"ListTagsForResource",
"ListCustomModels",
)
def _guardrail_mock(grounding_policy=None, fail_get=False):
"""Build a _make_api_call replacement returning one guardrail."""
def _mock(self, operation_name, kwarg):
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListGuardrails":
return {
"guardrails": [
{
"id": GUARDRAIL_ID,
"name": GUARDRAIL_NAME,
"arn": GUARDRAIL_ARN,
"status": "READY",
}
]
}
if operation_name == "GetGuardrail":
if fail_get:
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
response = {
"guardrailId": GUARDRAIL_ID,
"guardrailArn": GUARDRAIL_ARN,
"name": GUARDRAIL_NAME,
"status": "READY",
}
if grounding_policy is not None:
response["contextualGroundingPolicy"] = grounding_policy
return response
return make_api_call(self, operation_name, kwarg)
return _mock
def _filter(filter_type, threshold=0.75, action="BLOCK", enabled=True):
"""Build one contextual grounding filter; a None value omits that key.
Both action and enabled are optional members of the API shape, so omitting
either has to be expressible here to test the unknown paths.
"""
filter = {"type": filter_type, "threshold": threshold}
if action is not None:
filter["action"] = action
if enabled is not None:
filter["enabled"] = enabled
return filter
_mock_action_absent = _guardrail_mock(
{
"filters": [
_filter("GROUNDING", action=None),
_filter("RELEVANCE", action=None),
]
}
)
_mock_action_absent_one_filter = _guardrail_mock(
{"filters": [_filter("GROUNDING", action=None), _filter("RELEVANCE")]}
)
_mock_action_absent_with_zero_threshold = _guardrail_mock(
{
"filters": [
_filter("GROUNDING", action=None, threshold=0.0),
_filter("RELEVANCE"),
]
}
)
_mock_both_blocking = _guardrail_mock(
{"filters": [_filter("GROUNDING"), _filter("RELEVANCE")]}
)
_mock_no_policy = _guardrail_mock(None)
_mock_empty_filters = _guardrail_mock({"filters": []})
_mock_missing_relevance = _guardrail_mock({"filters": [_filter("GROUNDING")]})
_mock_missing_grounding = _guardrail_mock({"filters": [_filter("RELEVANCE")]})
# A policy carrying only an unrecognised filter type: the policy exists, so it is
# not the "no policy" case, yet both required types are absent at once.
_mock_missing_both = _guardrail_mock({"filters": [_filter("UNKNOWN_TYPE")]})
_mock_action_none = _guardrail_mock(
{"filters": [_filter("GROUNDING", action="NONE"), _filter("RELEVANCE")]}
)
_mock_zero_threshold = _guardrail_mock(
{"filters": [_filter("GROUNDING"), _filter("RELEVANCE", threshold=0.0)]}
)
_mock_disabled = _guardrail_mock(
{"filters": [_filter("GROUNDING", enabled=False), _filter("RELEVANCE")]}
)
_mock_both_disabled = _guardrail_mock(
{
"filters": [
_filter("GROUNDING", enabled=False),
_filter("RELEVANCE", enabled=False),
]
}
)
_mock_disabled_and_action_none = _guardrail_mock(
{
"filters": [
_filter("GROUNDING", enabled=False, action="NONE"),
_filter("RELEVANCE"),
]
}
)
_mock_enabled_absent = _guardrail_mock(
{
"filters": [
_filter("GROUNDING", enabled=None),
_filter("RELEVANCE", enabled=None),
]
}
)
_mock_enabled_absent_one_filter = _guardrail_mock(
{"filters": [_filter("GROUNDING", enabled=None), _filter("RELEVANCE")]}
)
_mock_enabled_absent_with_action_none = _guardrail_mock(
{
"filters": [
_filter("GROUNDING", enabled=None, action="NONE"),
_filter("RELEVANCE", enabled=None),
]
}
)
_mock_unreadable = _guardrail_mock(fail_get=True)
def _mock_empty(self, operation_name, kwarg):
"""No guardrails at all."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListGuardrails":
return {"guardrails": []}
return make_api_call(self, operation_name, kwarg)
def _mock_list_guardrails_denied(self, operation_name, kwarg):
"""ListGuardrails is denied, so the region's guardrails are unknown."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListGuardrails":
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
def _mock_unsupported_region(self, operation_name, kwarg):
"""The API is not available in the audited region."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListGuardrails":
raise ClientError(
{
"Error": {
"Code": "ValidationException",
"Message": "Bedrock is not supported in this region.",
}
},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
class Test_bedrock_guardrail_contextual_grounding_filter_enabled:
"""Unit tests for the bedrock_guardrail_contextual_grounding_filter_enabled check."""
def _run(self):
"""Import the service + check under the active mocks and execute."""
from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
aws_provider = set_mocked_aws_provider([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.bedrock.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_client",
new=Bedrock(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_guardrail_contextual_grounding_filter_enabled import (
bedrock_guardrail_contextual_grounding_filter_enabled,
)
return bedrock_guardrail_contextual_grounding_filter_enabled().execute()
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
@mock_aws
def test_no_resources(self):
"""No resources means no findings, not a spurious FAIL."""
assert self._run() == []
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
)
@mock_aws
def test_region_not_supported(self):
"""A ValidationException from the region must not raise; it yields no findings."""
assert self._run() == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_both_blocking)
@mock_aws
def test_both_filters_blocking_passes(self):
"""Both filter types enabled and blocking above a zero threshold is compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == GUARDRAIL_ID
assert result[0].resource_arn == GUARDRAIL_ARN
assert result[0].region == AWS_REGION_US_EAST_1
assert "blocks ungrounded and irrelevant responses" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_policy)
@mock_aws
def test_no_grounding_policy_fails(self):
"""No contextual grounding policy at all means nothing is ever detected."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "no contextual grounding policy" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_filters)
@mock_aws
def test_empty_filter_list_fails(self):
"""A policy present but carrying no filters is equivalent to no policy."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "no contextual grounding policy" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_missing_relevance
)
@mock_aws
def test_missing_relevance_filter_fails(self):
"""A GROUNDING filter alone leaves irrelevant answers unchecked."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "missing the RELEVANCE filter" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_missing_grounding
)
@mock_aws
def test_missing_grounding_filter_fails(self):
"""A RELEVANCE filter alone leaves unsupported answers unchecked."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "missing the GROUNDING filter" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_missing_both)
@mock_aws
def test_missing_both_filters_fails_with_plural_wording(self):
"""Both required types can be absent at once, so the nouns must agree.
The message lists the missing types, so hard-coding "filter" and "that
class" would read "missing the GROUNDING, RELEVANCE filter ... leaving
that class of ungrounded response unchecked".
"""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "missing the GROUNDING, RELEVANCE filters" in result[0].status_extended
assert "leaving those classes of ungrounded response" in (
result[0].status_extended
)
assert result[0].status_extended.endswith(".")
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_action_none)
@mock_aws
def test_action_none_fails(self):
"""Action NONE scores and reports without blocking, so it must FAIL."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "GROUNDING filter uses action NONE" in result[0].status_extended
assert "without blocking" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_zero_threshold)
@mock_aws
def test_zero_threshold_fails(self):
"""A threshold of 0 can never be tripped, so the filter blocks nothing."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "RELEVANCE filter has a threshold of 0" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_disabled)
@mock_aws
def test_disabled_filter_fails(self):
"""enabled: false runs no evaluation, so BLOCK and a real threshold are inert."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "GROUNDING filter is disabled" in result[0].status_extended
assert "its evaluation never runs" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_both_disabled)
@mock_aws
def test_both_filters_disabled_fails(self):
"""Both filters disabled reports both, not just the first."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "GROUNDING filter is disabled" in result[0].status_extended
assert "RELEVANCE filter is disabled" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_disabled_and_action_none
)
@mock_aws
def test_disabled_reported_ahead_of_action(self):
"""Disabled is the operative defect: the action is never reached."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "GROUNDING filter is disabled" in result[0].status_extended
assert "action NONE" not in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_enabled_absent)
@mock_aws
def test_absent_enabled_is_manual_not_pass(self):
"""enabled is optional with no documented default, so absent is unknown."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"GROUNDING filter omits enabled, RELEVANCE filter omits enabled"
in result[0].status_extended
)
# Two unknown filters take the plural subject.
assert "so whether they block is unknown" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_enabled_absent_one_filter
)
@mock_aws
def test_absent_enabled_on_one_filter_is_manual(self):
"""One filter omitting enabled is enough to make the answer unknown."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "GROUNDING filter omits enabled" in result[0].status_extended
# action was present, so it must not be reported as missing.
assert "omits action" not in result[0].status_extended
assert "RELEVANCE" not in result[0].status_extended
assert "so whether it blocks is unknown" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_action_absent)
@mock_aws
def test_absent_action_is_manual_not_fail(self):
"""action is optional with no documented default, so omitting it is unknown.
Treating an absent action as NONE would assert a misconfiguration the
response never stated, and would print the literal None as if it were an
AWS enum value. It is reported the same way as an absent enabled.
"""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "None" not in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_action_absent_one_filter
)
@mock_aws
def test_absent_action_on_one_filter_is_manual(self):
"""One filter omitting action is enough to make the answer unknown."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "GROUNDING filter omits action" in result[0].status_extended
# enabled was present, so it must not be reported as missing.
assert "omits enabled" not in result[0].status_extended
assert "RELEVANCE" not in result[0].status_extended
assert "so whether it blocks is unknown" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_mock_action_absent_with_zero_threshold,
)
@mock_aws
def test_absent_action_does_not_mask_a_definite_defect(self):
"""An unknown action must not downgrade a real threshold defect to MANUAL."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "threshold" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_mock_enabled_absent_with_action_none,
)
@mock_aws
def test_absent_enabled_does_not_mask_a_definite_defect(self):
"""An unknown enabled must not downgrade a real action defect to MANUAL."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "GROUNDING filter uses action NONE" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable)
@mock_aws
def test_detail_unreadable_is_manual_not_pass(self):
"""A failed GetGuardrail must not be reported as compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be retrieved" in result[0].status_extended
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_list_guardrails_denied
)
@mock_aws
def test_list_guardrails_denied_is_manual_not_silence(self):
"""A denied ListGuardrails must report MANUAL for the region, not vanish.
Without this the Region is indistinguishable from one holding no
guardrails, which is the same silent-inventory gap the sibling checks
report against custom-model/unknown and knowledge-base/unknown.
"""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "guardrail/unknown"
assert (
result[0].resource_arn
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/unknown"
)
assert "could not be listed" in result[0].status_extended
assert "AccessDeniedException" in result[0].status_extended
assert result[0].status_extended.endswith(".")
@@ -0,0 +1,338 @@
from unittest import mock
import botocore
from botocore.exceptions import ClientError
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
make_api_call = botocore.client.BaseClient._make_api_call
KB_ID = "test-kb-id"
KB_NAME = "test-knowledge-base"
KB_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:knowledge-base/{KB_ID}"
DS_ID = "test-ds-id"
DS_NAME = "test-data-source"
DS_ARN = f"{KB_ARN}/data-source/{DS_ID}"
KMS_KEY_ARN = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/test-key-id"
# Operations the BedrockAgent constructor calls that these tests do not exercise.
_UNUSED_OPERATIONS = (
"ListAgents",
"GetAgent",
"ListPrompts",
"GetPrompt",
"ListTagsForResource",
)
def _knowledge_base_mock(kms_key_arn=None, fail_get=False):
"""Build a _make_api_call replacement returning one KB with one data source."""
def _mock(self, operation_name, kwarg):
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListKnowledgeBases":
return {
"knowledgeBaseSummaries": [
{
"knowledgeBaseId": KB_ID,
"name": KB_NAME,
"status": "ACTIVE",
}
]
}
if operation_name == "ListDataSources":
return {
"dataSourceSummaries": [
{
"knowledgeBaseId": KB_ID,
"dataSourceId": DS_ID,
"name": DS_NAME,
"status": "AVAILABLE",
}
]
}
if operation_name == "GetDataSource":
if fail_get:
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
# GetDataSource nests its payload under a top-level dataSource key.
data_source = {
"knowledgeBaseId": KB_ID,
"dataSourceId": DS_ID,
"name": DS_NAME,
"status": "AVAILABLE",
}
if kms_key_arn is not None:
data_source["serverSideEncryptionConfiguration"] = {
"kmsKeyArn": kms_key_arn
}
return {"dataSource": data_source}
return make_api_call(self, operation_name, kwarg)
return _mock
_mock_with_cmk = _knowledge_base_mock(KMS_KEY_ARN)
_mock_without_cmk = _knowledge_base_mock(None)
_mock_empty_cmk = _knowledge_base_mock("")
_mock_unreadable = _knowledge_base_mock(fail_get=True)
def _mock_empty(self, operation_name, kwarg):
"""No knowledge bases at all."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListKnowledgeBases":
return {"knowledgeBaseSummaries": []}
return make_api_call(self, operation_name, kwarg)
def _mock_no_data_sources(self, operation_name, kwarg):
"""A knowledge base with no data sources produces no findings."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListKnowledgeBases":
return {
"knowledgeBaseSummaries": [
{"knowledgeBaseId": KB_ID, "name": KB_NAME, "status": "ACTIVE"}
]
}
if operation_name == "ListDataSources":
return {"dataSourceSummaries": []}
return make_api_call(self, operation_name, kwarg)
def _mock_unsupported_region(self, operation_name, kwarg):
"""The API is not available in the audited region."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListKnowledgeBases":
raise ClientError(
{
"Error": {
"Code": "ValidationException",
"Message": "Bedrock Agent is not supported in this region.",
}
},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
def _mock_list_kb_denied(self, operation_name, kwarg):
"""ListKnowledgeBases is denied, so the region's knowledge bases are unknown."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListKnowledgeBases":
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
def _mock_list_ds_denied(self, operation_name, kwarg):
"""The knowledge base is visible but its data sources cannot be listed."""
if operation_name in _UNUSED_OPERATIONS:
return {}
if operation_name == "ListKnowledgeBases":
return {
"knowledgeBaseSummaries": [
{"knowledgeBaseId": KB_ID, "name": KB_NAME, "status": "ACTIVE"}
]
}
if operation_name == "ListDataSources":
raise ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return make_api_call(self, operation_name, kwarg)
class Test_bedrock_knowledge_base_encrypted_with_cmk:
"""Unit tests for the bedrock_knowledge_base_encrypted_with_cmk check."""
def _run(self):
"""Import the service + check under the active mocks and execute."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider([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.bedrock.bedrock_knowledge_base_encrypted_with_cmk.bedrock_knowledge_base_encrypted_with_cmk.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_knowledge_base_encrypted_with_cmk.bedrock_knowledge_base_encrypted_with_cmk import (
bedrock_knowledge_base_encrypted_with_cmk,
)
return bedrock_knowledge_base_encrypted_with_cmk().execute()
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty)
@mock_aws
def test_no_resources(self):
"""No resources means no findings, not a spurious FAIL."""
assert self._run() == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_data_sources)
@mock_aws
def test_knowledge_base_without_data_sources(self):
"""Findings are per data source, so a KB with none produces nothing."""
assert self._run() == []
@mock.patch(
"botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region
)
@mock_aws
def test_region_not_supported(self):
"""A ValidationException from the region must not raise; it yields no findings."""
assert self._run() == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_with_cmk)
@mock_aws
def test_cmk_present_passes(self):
"""A data source with kmsKeyArn set is compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == DS_ID
assert result[0].resource_arn == DS_ARN
assert result[0].region == AWS_REGION_US_EAST_1
assert (
result[0].status_extended
== f"Bedrock knowledge base {KB_NAME} data source {DS_NAME} is encrypted with a customer-managed KMS key in region {AWS_REGION_US_EAST_1}."
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk)
@mock_aws
def test_no_cmk_fails(self):
"""An absent kmsKeyArn means an AWS-owned key is in use."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert KB_NAME in result[0].status_extended
assert "is not encrypted with a customer-managed KMS key" in (
result[0].status_extended
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_cmk)
@mock_aws
def test_empty_cmk_fails(self):
"""An empty kmsKeyArn string is not a key."""
result = self._run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "is not encrypted with a customer-managed KMS key" in (
result[0].status_extended
)
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable)
@mock_aws
def test_detail_unreadable_is_manual_not_pass(self):
"""A failed GetDataSource must not be reported as compliant."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be retrieved" in result[0].status_extended
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_kb_denied)
@mock_aws
def test_list_knowledge_bases_denied_is_manual_not_silence(self):
"""A denied ListKnowledgeBases must report MANUAL for the region."""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "knowledge-base/unknown"
assert (
result[0].resource_arn
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:knowledge-base/unknown"
)
assert "could not be listed" in result[0].status_extended
assert "AccessDeniedException" in result[0].status_extended
assert result[0].status_extended.endswith(".")
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_ds_denied)
@mock_aws
def test_list_data_sources_denied_is_manual_not_silence(self):
"""A knowledge base whose data sources cannot be listed must still report.
Reporting nothing would drop the knowledge base from the output, which is
indistinguishable from one that genuinely has no data sources.
"""
result = self._run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].resource_arn == KB_ARN
assert KB_NAME in result[0].status_extended
assert "data sources could not be listed" in result[0].status_extended
# The message names why, like the region-level and detail-level ones do.
assert "AccessDeniedException" in result[0].status_extended
assert result[0].status_extended.endswith(".")
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_data_sources)
@mock_aws
def test_listed_but_empty_marks_the_knowledge_base_as_listed(self):
"""A successful but empty ListDataSources is "none", not "unknown".
Distinct from test_knowledge_base_without_data_sources, which only asserts
the empty result: this asserts the service state that produces it, so the
over-correction of reporting MANUAL for a genuinely empty knowledge base
cannot regress silently.
"""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
service = BedrockAgent(aws_provider)
assert service.knowledge_bases, "the knowledge base must be discovered"
assert all(
knowledge_base.data_sources_listed
for knowledge_base in service.knowledge_bases.values()
)
assert service.data_sources == {}
assert service.knowledge_bases_scan_errors == {}
assert self._run() == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk)
@mock_aws
def test_scoping_by_knowledge_base_arn_keeps_its_data_sources(self):
"""A scan scoped to the knowledge base ARN must still see its data sources.
AWS exposes no ARN for a Bedrock data source, so the one built here is
synthetic and can never equal a user-supplied --resource-arn. Filtering on
it would keep the knowledge base, silently drop every data source, and
leave the check reporting nothing for an in-scope knowledge base.
"""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
aws_provider._audit_resources = [KB_ARN]
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
service = BedrockAgent(aws_provider)
assert service.knowledge_bases, "the scoped knowledge base must be kept"
assert service.data_sources, "its data sources must not be filtered out"
assert all(
ds.knowledge_base_id == KB_ID for ds in service.data_sources.values()
)
@@ -2,6 +2,7 @@ from unittest import mock
from unittest.mock import MagicMock
import botocore
import pytest
from boto3 import client
from moto import mock_aws
@@ -283,11 +284,20 @@ class TestBedrockPagination:
class TestBedrockAgentPagination:
"""Test suite for Bedrock Agent pagination logic."""
def test_list_agents_pagination(self):
"""Test that list_agents iterates through all pages."""
# Mock the audit_info
@pytest.mark.parametrize("partition", ["aws", "aws-us-gov", "aws-cn"])
def test_list_agents_pagination(self, partition):
"""Test that list_agents iterates through all pages, in every partition.
The ARN is built from the audited partition, so GovCloud and China must
produce aws-us-gov/aws-cn ARNs. A hardcoded `arn:aws:` here yielded an ARN
that does not exist in those partitions, and exact --resource-arn matching
against it could never succeed.
"""
# Mock the audit_info. AWSService reads the partition off provider.identity,
# so setting audited_partition alone leaves a MagicMock in the ARN.
audit_info = MagicMock()
audit_info.audited_partition = "aws"
audit_info.identity.partition = partition
audit_info.audited_partition = partition
audit_info.audited_account = "123456789012"
audit_info.audit_resources = None
@@ -322,6 +332,7 @@ class TestBedrockAgentPagination:
bedrock_agent_service = BedrockAgent(audit_info)
bedrock_agent_service.regional_clients = {"us-east-1": regional_client}
bedrock_agent_service.agents = {} # Clear init side effects
bedrock_agent_service.all_agents = {}
bedrock_agent_service.audited_account = "123456789012"
# Run method
@@ -329,14 +340,16 @@ class TestBedrockAgentPagination:
# Assertions
assert len(bedrock_agent_service.agents) == 2
assert (
"arn:aws:bedrock:us-east-1:123456789012:agent/agent-1"
in bedrock_agent_service.agents
)
assert (
"arn:aws:bedrock:us-east-1:123456789012:agent/agent-2"
in bedrock_agent_service.agents
)
for agent_id in ("agent-1", "agent-2"):
expected_arn = (
f"arn:{partition}:bedrock:us-east-1:123456789012:agent/{agent_id}"
)
assert expected_arn in bedrock_agent_service.agents
# With no --resource-arn, the complete inventory and the reported set
# hold the very same objects.
assert bedrock_agent_service.all_agents[expected_arn] is (
bedrock_agent_service.agents[expected_arn]
)
# Verify paginator was used
regional_client.get_paginator.assert_called_once_with("list_agents")
@@ -6,6 +6,7 @@ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
SecurityGroupRule,
SecurityGroups,
VPCs,
rule_source_is_open,
)
from tests.providers.huaweicloud.huaweicloud_fixtures import (
set_mocked_huaweicloud_provider,
@@ -24,6 +25,18 @@ def _provider_with_client(regional_client):
class TestVPCService:
def test_rule_with_remote_address_group_is_not_open(self):
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ethertype="IPv4",
remote_ip_prefix="",
remote_address_group_id="address-group-1",
)
assert rule_source_is_open(rule) is False
def test_list_vpcs_and_security_groups_parses(self):
vpc = SimpleNamespace(
id="vpc-1",
@@ -36,12 +49,14 @@ class TestVPCService:
rule = SimpleNamespace(
id="rule-1",
direction="ingress",
action="deny",
protocol="tcp",
ethertype="IPv4",
port_range_min=22,
port_range_max=22,
remote_ip_prefix="0.0.0.0/0",
remote_group_id="",
remote_address_group_id="address-group-1",
description="ssh open",
)
sg = SimpleNamespace(
@@ -82,8 +97,10 @@ class TestVPCService:
parsed_rule = parsed_sg.rules[0]
assert isinstance(parsed_rule, SecurityGroupRule)
assert parsed_rule.direction == "ingress"
assert parsed_rule.action == "deny"
assert parsed_rule.protocol == "tcp"
assert parsed_rule.remote_ip_prefix == "0.0.0.0/0"
assert parsed_rule.remote_address_group_id == "address-group-1"
assert parsed_rule.port_range_min == 22
assert parsed_rule.port_range_max == 22
@@ -94,12 +111,14 @@ class TestVPCService:
rule = SimpleNamespace(
id="rule-1",
direction=None,
action=None,
protocol=None,
ethertype=None,
port_range_min=None,
port_range_max=None,
remote_ip_prefix=None,
remote_group_id=None,
remote_address_group_id=None,
description=None,
)
sg = SimpleNamespace(
@@ -134,8 +153,10 @@ class TestVPCService:
assert parsed_sg.name == "sg-1" # falls back to id
assert parsed_sg.vpc_id == ""
parsed_rule = parsed_sg.rules[0]
assert parsed_rule.action == "allow"
assert parsed_rule.protocol == ""
assert parsed_rule.remote_ip_prefix == ""
assert parsed_rule.remote_address_group_id == ""
assert parsed_rule.description == ""
assert parsed_rule.direction == ""
@@ -0,0 +1,277 @@
from unittest import mock
from tests.providers.huaweicloud.huaweicloud_fixtures import (
set_mocked_huaweicloud_provider,
)
class TestVpcSecurityGroupOpenEgress:
def test_no_open_egress_passes(self):
vpc_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_huaweicloud_provider(),
),
mock.patch(
"prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client",
new=vpc_client,
),
):
from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import (
vpc_security_group_open_egress,
)
from prowler.providers.huaweicloud.services.vpc.vpc_service import (
SecurityGroupRule,
SecurityGroups,
)
sg = SecurityGroups(
id="sg-1",
name="safe-sg",
region="la-south-2",
vpc_id="vpc-1",
rules=[
SecurityGroupRule(
id="rule-1",
direction="egress",
protocol="tcp",
ethertype="IPv4",
remote_ip_prefix="10.0.0.0/24",
port_range_min=443,
port_range_max=443,
),
SecurityGroupRule(
id="deny-rule",
direction="egress",
action="deny",
protocol="",
ethertype="IPv4",
remote_ip_prefix="",
),
SecurityGroupRule(
id="address-group-rule",
direction="egress",
action="allow",
protocol="",
ethertype="IPv4",
remote_ip_prefix="",
remote_address_group_id="address-group-1",
),
],
)
vpc_client.security_groups = {"sg-1": sg}
vpc_client.audited_account = "123456789012"
check = vpc_security_group_open_egress()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
"Security group safe-sg (sg-1) does not allow open egress to "
"the internet."
)
assert result[0].resource_id == "sg-1"
assert result[0].resource_name == "safe-sg"
assert result[0].resource_arn == (
"huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1"
)
assert result[0].region == "la-south-2"
def test_open_egress_ipv4_fails(self):
vpc_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_huaweicloud_provider(),
),
mock.patch(
"prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client",
new=vpc_client,
),
):
from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import (
vpc_security_group_open_egress,
)
from prowler.providers.huaweicloud.services.vpc.vpc_service import (
SecurityGroupRule,
SecurityGroups,
)
sg = SecurityGroups(
id="sg-1",
name="open-egress-sg",
region="la-south-2",
vpc_id="vpc-1",
rules=[
SecurityGroupRule(
id="rule-1",
direction="egress",
protocol="tcp",
ethertype="IPv4",
remote_ip_prefix="0.0.0.0/0",
port_range_min=80,
port_range_max=80,
),
],
)
vpc_client.security_groups = {"sg-1": sg}
vpc_client.audited_account = "123456789012"
check = vpc_security_group_open_egress()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
"Security group open-egress-sg (sg-1) allows open egress "
"(0.0.0.0/0) to the internet."
)
assert result[0].resource_id == "sg-1"
assert result[0].resource_name == "open-egress-sg"
assert result[0].resource_arn == (
"huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1"
)
assert result[0].region == "la-south-2"
def test_open_egress_ipv6_fails(self):
vpc_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_huaweicloud_provider(),
),
mock.patch(
"prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client",
new=vpc_client,
),
):
from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import (
vpc_security_group_open_egress,
)
from prowler.providers.huaweicloud.services.vpc.vpc_service import (
SecurityGroupRule,
SecurityGroups,
)
sg = SecurityGroups(
id="sg-1",
name="open-egress-sg-ipv6",
region="la-south-2",
vpc_id="vpc-1",
rules=[
SecurityGroupRule(
id="rule-1",
direction="egress",
protocol="tcp",
ethertype="IPv6",
remote_ip_prefix="::/0",
port_range_min=443,
port_range_max=443,
),
],
)
vpc_client.security_groups = {"sg-1": sg}
vpc_client.audited_account = "123456789012"
check = vpc_security_group_open_egress()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
"Security group open-egress-sg-ipv6 (sg-1) allows open egress "
"(::/0) to the internet."
)
assert result[0].resource_id == "sg-1"
assert result[0].resource_name == "open-egress-sg-ipv6"
assert result[0].resource_arn == (
"huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1"
)
assert result[0].region == "la-south-2"
def test_open_egress_with_empty_destination_fails(self):
vpc_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_huaweicloud_provider(),
),
mock.patch(
"prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client",
new=vpc_client,
),
):
from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import (
vpc_security_group_open_egress,
)
from prowler.providers.huaweicloud.services.vpc.vpc_service import (
SecurityGroupRule,
SecurityGroups,
)
sg = SecurityGroups(
id="sg-1",
name="open-egress-sg-empty-destination",
region="la-south-2",
vpc_id="vpc-1",
rules=[
SecurityGroupRule(
id="rule-1",
direction="egress",
action="allow",
protocol="",
ethertype="IPv4",
remote_ip_prefix="",
remote_address_group_id="",
),
],
)
vpc_client.security_groups = {"sg-1": sg}
vpc_client.audited_account = "123456789012"
check = vpc_security_group_open_egress()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
"Security group open-egress-sg-empty-destination (sg-1) allows "
"open egress (all destinations) to the internet."
)
assert result[0].resource_id == "sg-1"
assert result[0].resource_name == "open-egress-sg-empty-destination"
assert result[0].resource_arn == (
"huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1"
)
assert result[0].region == "la-south-2"
def test_no_security_groups(self):
vpc_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_huaweicloud_provider(),
),
mock.patch(
"prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client",
new=vpc_client,
),
):
from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import (
vpc_security_group_open_egress,
)
vpc_client.security_groups = {}
vpc_client.audited_account = "123456789012"
check = vpc_security_group_open_egress()
result = check.execute()
assert len(result) == 0
@@ -0,0 +1,149 @@
from unittest import mock
from prowler.providers.stackit.services.ske.ske_service import Cluster
from tests.providers.stackit.stackit_fixtures import (
STACKIT_PROJECT_ID,
set_mocked_stackit_provider,
)
class Test_ske_cluster_no_public_endpoint:
def _run_check(self, ske_client):
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_stackit_provider(),
),
mock.patch(
"prowler.providers.stackit.services.ske.ske_service.SKEService",
new=ske_client,
) as service_client,
mock.patch(
"prowler.providers.stackit.services.ske.ske_client.ske_client",
new=service_client,
),
):
from prowler.providers.stackit.services.ske.ske_cluster_no_public_endpoint.ske_cluster_no_public_endpoint import (
ske_cluster_no_public_endpoint,
)
check = ske_cluster_no_public_endpoint()
return check.execute()
def _cluster(self, name="test-cluster", **kwargs):
defaults = {
"id": name,
"name": name,
"project_id": STACKIT_PROJECT_ID,
"region": "eu01",
}
defaults.update(kwargs)
return Cluster(**defaults)
def test_no_clusters(self):
ske_client = mock.MagicMock
ske_client.clusters = []
result = self._run_check(ske_client)
assert len(result) == 0
def test_cluster_without_acl_is_public(self):
ske_client = mock.MagicMock
ske_client.clusters = [self._cluster(name="open-cluster", acl_enabled=False)]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SKE cluster open-cluster exposes its Kubernetes API endpoint to the "
"internet because the ACL extension is not enabled."
)
assert result[0].resource_id == "open-cluster"
assert result[0].resource_name == "open-cluster"
assert result[0].project_id == STACKIT_PROJECT_ID
assert result[0].location == "eu01"
def test_cluster_with_unrestricted_ipv4_cidr_is_public(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(
name="wide-cluster",
acl_enabled=True,
allowed_cidrs=["10.0.0.0/8", "0.0.0.0/0"],
)
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SKE cluster wide-cluster exposes its Kubernetes API endpoint to the "
"internet because its ACL allows unrestricted access from 0.0.0.0/0."
)
def test_cluster_with_unrestricted_ipv6_cidr_is_public(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(name="v6-cluster", acl_enabled=True, allowed_cidrs=["::/0"])
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "unrestricted access from ::/0." in result[0].status_extended
def test_cluster_with_restricted_acl_passes(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(
name="locked-cluster",
access_scope="PUBLIC",
acl_enabled=True,
allowed_cidrs=["10.0.0.0/8", "192.0.2.0/24"],
)
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SKE cluster locked-cluster restricts access to its Kubernetes API "
"endpoint to 2 allowed CIDR(s)."
)
def test_cluster_with_private_control_plane_passes_without_acl(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(name="sna-cluster", access_scope="SNA", acl_enabled=False)
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SKE cluster sna-cluster has a private control plane and its Kubernetes "
"API endpoint is not reachable from the internet."
)
def test_mixed_clusters_report_independently(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(name="open-cluster", acl_enabled=False),
self._cluster(
name="locked-cluster", acl_enabled=True, allowed_cidrs=["10.0.0.0/8"]
),
self._cluster(name="sna-cluster", access_scope="SNA"),
]
result = self._run_check(ske_client)
assert len(result) == 3
assert [report.status for report in result] == ["FAIL", "PASS", "PASS"]
assert [report.resource_name for report in result] == [
"open-cluster",
"locked-cluster",
"sna-cluster",
]
@@ -0,0 +1,526 @@
from unittest.mock import MagicMock, patch
import pytest
from prowler.providers.stackit.exceptions.exceptions import StackITInvalidTokenError
from prowler.providers.stackit.services.ske.ske_service import Cluster, SKEService
from tests.providers.stackit.stackit_fixtures import (
STACKIT_PROJECT_ID,
set_mocked_stackit_provider,
)
def mock_ske_fetch_all_regions(_):
"""Mock the _fetch_all_regions method to avoid real API calls."""
@patch(
"prowler.providers.stackit.services.ske.ske_service.SKEService._fetch_all_regions",
new=mock_ske_fetch_all_regions,
)
class Test_SKE_Service:
def test_service_initialization(self):
"""Test that the SKE service initializes correctly."""
ske_service = SKEService(set_mocked_stackit_provider())
assert ske_service.project_id == STACKIT_PROJECT_ID
assert ske_service.service_account_key_path is not None
assert isinstance(ske_service.clusters, list)
def test_service_service_account_key_path(self):
"""Test that the service correctly extracts the SA key path from provider."""
custom_path = "/tmp/custom-sa.json"
provider = set_mocked_stackit_provider(service_account_key_path=custom_path)
ske_service = SKEService(provider)
assert ske_service.service_account_key_path == custom_path
def test_service_requests_ske_regional_clients(self):
"""Test that the service asks the provider for SKE regional clients."""
provider = set_mocked_stackit_provider()
SKEService(provider)
provider.generate_regional_clients.assert_called_once_with("ske")
class Test_SKE_Service_ExtractItems:
"""Tests for the _extract_items response normalization helper."""
def test_dict_response(self):
assert SKEService._extract_items({"items": [1, 2]}, "list_clusters") == [1, 2]
def test_dict_response_without_items_key(self):
assert SKEService._extract_items({}, "list_clusters") == []
def test_list_response(self):
assert SKEService._extract_items(["cluster"], "list_clusters") == ["cluster"]
def test_model_with_items_attribute(self):
response = MagicMock(spec=["items"])
response.items = ["cluster"]
assert SKEService._extract_items(response, "list_clusters") == ["cluster"]
def test_model_with_callable_items_is_rejected(self):
# A bare MagicMock exposes ``items`` as a callable, which must not be
# mistaken for the items list.
assert SKEService._extract_items(MagicMock(), "list_clusters") == []
def test_unexpected_response_type(self):
assert SKEService._extract_items(object(), "list_clusters") == []
class Test_SKE_Service_GetField:
"""Tests for the _get_field dict/model accessor."""
def test_none_item_returns_default(self):
assert SKEService._get_field(None, "enabled", default="fallback") == "fallback"
def test_dict_alias_key_is_matched(self):
item = {"allowedCidrs": ["10.0.0.0/8"]}
assert SKEService._get_field(item, "allowed_cidrs", "allowedCidrs") == [
"10.0.0.0/8"
]
def test_dict_missing_keys_returns_default(self):
assert SKEService._get_field({}, "enabled", default=[]) == []
def test_dict_false_value_is_returned_not_treated_as_missing(self):
assert (
SKEService._get_field({"enabled": False}, "enabled", default=True) is False
)
def test_model_attribute_is_read(self):
item = MagicMock(spec=["enabled"])
item.enabled = True
assert SKEService._get_field(item, "enabled") is True
def test_model_false_attribute_is_returned(self):
item = MagicMock(spec=["enabled"])
item.enabled = False
assert SKEService._get_field(item, "enabled", default=True) is False
def test_model_missing_attribute_returns_default(self):
item = MagicMock(spec=["other"])
assert SKEService._get_field(item, "enabled", default="fallback") == "fallback"
class Test_SKE_Service_ParseAccessScope:
"""Tests for _parse_access_scope."""
def test_missing_network_returns_none(self):
assert SKEService._parse_access_scope({}) is None
def test_missing_control_plane_returns_none(self):
assert SKEService._parse_access_scope({"network": {"id": "net"}}) is None
def test_missing_access_scope_returns_none(self):
assert SKEService._parse_access_scope({"network": {"controlPlane": {}}}) is None
def test_camel_case_dict_scope(self):
cluster_data = {"network": {"controlPlane": {"accessScope": "SNA"}}}
assert SKEService._parse_access_scope(cluster_data) == "SNA"
def test_snake_case_dict_scope(self):
cluster_data = {"network": {"control_plane": {"access_scope": "PUBLIC"}}}
assert SKEService._parse_access_scope(cluster_data) == "PUBLIC"
def test_sdk_enum_is_normalized_to_wire_value(self):
# ``AccessScope`` is a ``str`` Enum whose ``str()`` renders as
# "AccessScope.SNA"; the parser must yield the "SNA" wire value.
from stackit.ske.models.access_scope import AccessScope
cluster_data = {"network": {"controlPlane": {"accessScope": AccessScope.SNA}}}
assert SKEService._parse_access_scope(cluster_data) == "SNA"
class Test_SKE_Service_ParseAcl:
"""Tests for _parse_acl."""
def test_missing_extensions_reports_no_acl(self):
assert SKEService._parse_acl({}) == (False, [])
def test_extensions_without_acl_reports_no_acl(self):
assert SKEService._parse_acl({"extensions": {"dns": {}}}) == (False, [])
def test_disabled_acl_keeps_its_cidrs(self):
cluster_data = {
"extensions": {"acl": {"enabled": False, "allowedCidrs": ["10.0.0.0/8"]}}
}
assert SKEService._parse_acl(cluster_data) == (False, ["10.0.0.0/8"])
def test_enabled_acl_camel_case_cidrs(self):
cluster_data = {
"extensions": {"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}}
}
assert SKEService._parse_acl(cluster_data) == (True, ["10.0.0.0/8"])
def test_enabled_acl_snake_case_cidrs(self):
cluster_data = {
"extensions": {"acl": {"enabled": True, "allowed_cidrs": ["10.0.0.0/8"]}}
}
assert SKEService._parse_acl(cluster_data) == (True, ["10.0.0.0/8"])
def test_enabled_acl_without_cidrs(self):
cluster_data = {"extensions": {"acl": {"enabled": True}}}
assert SKEService._parse_acl(cluster_data) == (True, [])
class Test_SKE_Service_HandleApiCall:
"""Tests for the centralized _handle_api_call wrapper."""
def _service(self):
service = object.__new__(SKEService)
service.provider = MagicMock()
return service
def test_returns_api_response(self):
service = self._service()
api_function = MagicMock(return_value={"items": []})
assert service._handle_api_call(api_function, project_id="p") == {"items": []}
api_function.assert_called_once_with(project_id="p")
def test_delegates_errors_to_provider_and_reraises(self):
service = self._service()
error = ValueError("boom")
api_function = MagicMock(side_effect=error)
with pytest.raises(ValueError):
service._handle_api_call(api_function)
service.provider.handle_api_error.assert_called_once_with(error)
class Test_SKE_Service_ListClusters:
"""Tests for _list_clusters."""
def _service(self):
service = object.__new__(SKEService)
service.provider = MagicMock()
service.project_id = STACKIT_PROJECT_ID
service.clusters = []
return service
def test_list_clusters_without_client_is_noop(self):
"""A missing regional client is logged and skipped, not fatal."""
service = self._service()
service._list_clusters(None, "eu01")
assert service.clusters == []
def test_list_clusters_populates_clusters(self):
service = self._service()
client = MagicMock()
client.list_clusters.return_value = {
"items": [
{"name": "open-cluster"},
{
"name": "locked-cluster",
"extensions": {
"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}
},
"network": {"controlPlane": {"accessScope": "PUBLIC"}},
},
]
}
service._list_clusters(client, "eu01")
assert [cluster.name for cluster in service.clusters] == [
"open-cluster",
"locked-cluster",
]
# SKE has no separate cluster id; the name doubles as the identifier.
assert service.clusters[0].id == "open-cluster"
assert service.clusters[0].acl_enabled is False
assert service.clusters[0].access_scope is None
assert service.clusters[1].acl_enabled is True
assert service.clusters[1].allowed_cidrs == ["10.0.0.0/8"]
assert service.clusters[1].access_scope == "PUBLIC"
assert all(
cluster.project_id == STACKIT_PROJECT_ID and cluster.region == "eu01"
for cluster in service.clusters
)
def test_cluster_processing_error_is_skipped(self):
"""A cluster that raises while being read is skipped, not fatal."""
class MalformedCluster:
@property
def name(self):
raise ValueError("malformed cluster")
service = self._service()
client = MagicMock()
client.list_clusters.return_value = {"items": [MalformedCluster()]}
service._list_clusters(client, "eu01")
assert service.clusters == []
class Test_SKE_Service_FetchAllRegions:
"""Tests for the region fetch loop and its error semantics."""
class _NotFound(Exception):
status = 404
class _Unauthorized(Exception):
status = 401
class _ServerError(Exception):
status = 500
class _ServiceNotEnabled(Exception):
# Body copied from a live SKE 403 for a region the project never enabled.
status = 403
body = (
'{"timestamp":"2026-08-20T09:51:33Z","status":403,'
'"error":"Forbidden","message":"Service not enabled"}'
)
class _Forbidden(Exception):
status = 403
body = '{"status":403,"error":"Forbidden","message":"Access denied"}'
def _service(self, regional_clients):
from prowler.providers.stackit.stackit_provider import StackitProvider
service = object.__new__(SKEService)
service.provider = MagicMock()
# Reuse the real centralized error handler so 401/403/404 semantics
# match production.
service.provider.handle_api_error = StackitProvider.handle_api_error
service.project_id = STACKIT_PROJECT_ID
service.regional_clients = regional_clients
service.clusters = []
return service
def _good_client(self, cluster_name="cluster-eu01"):
client = MagicMock()
client.list_clusters.return_value = {"items": [{"name": cluster_name}]}
return client
def _failing_client(self, error):
client = MagicMock()
client.list_clusters.side_effect = error
return client
def test_skips_region_where_project_is_absent(self):
service = self._service(
{
"eu01": self._good_client(),
"eu02": self._failing_client(self._NotFound()),
}
)
service._fetch_all_regions()
# eu01 cluster is collected; the eu02 404 is skipped silently.
assert [cluster.name for cluster in service.clusters] == ["cluster-eu01"]
def test_skips_region_where_ske_is_not_enabled(self):
service = self._service(
{
"eu01": self._good_client(),
"eu02": self._failing_client(self._ServiceNotEnabled()),
}
)
service._fetch_all_regions()
# A project may enable SKE per region; the eu02 403 "Service not
# enabled" is a skip, not the credentials failure a bare 403 implies.
assert [cluster.name for cluster in service.clusters] == ["cluster-eu01"]
def test_permission_denied_still_aborts_the_scan(self):
service = self._service({"eu01": self._failing_client(self._Forbidden())})
with pytest.raises(StackITInvalidTokenError):
service._fetch_all_regions()
def test_invalid_token_aborts_the_scan(self):
service = self._service({"eu01": self._failing_client(self._Unauthorized())})
with pytest.raises(StackITInvalidTokenError):
service._fetch_all_regions()
def test_unexpected_error_propagates(self):
service = self._service({"eu01": self._failing_client(self._ServerError())})
with pytest.raises(self._ServerError):
service._fetch_all_regions()
class Test_SKE_Service_SdkModelShapes:
"""Regression coverage for object-shaped (SDK model) API responses.
``_extract_items`` accepts both raw dicts and SDK models, so the cluster
parsing has to read either shape. Reading only dict keys would silently drop
the ACL of an SDK-model cluster and report a restricted cluster as exposed,
or worse, miss the ACL of an exposed one.
"""
@staticmethod
def _sdk_cluster(name, acl=None, access_scope=None):
"""Build a real ``stackit.ske`` Cluster model with optional ACL and scope."""
from stackit.ske.models.cluster import Cluster as SDKCluster
from stackit.ske.models.extension import Extension
from stackit.ske.models.image import Image
from stackit.ske.models.kubernetes import Kubernetes
from stackit.ske.models.machine import Machine
from stackit.ske.models.network import Network
from stackit.ske.models.nodepool import Nodepool
from stackit.ske.models.v2_control_plane_network import V2ControlPlaneNetwork
from stackit.ske.models.volume import Volume
nodepool = Nodepool(
name="np",
availabilityZones=["eu01-1"],
maximum=1,
minimum=1,
machine=Machine(image=Image(name="flatcar", version="1.0"), type="g1.2"),
volume=Volume(size=20),
)
return SDKCluster(
name=name,
kubernetes=Kubernetes(version="1.31.0"),
nodepools=[nodepool],
extensions=Extension(acl=acl) if acl is not None else None,
network=(
Network(controlPlane=V2ControlPlaneNetwork(accessScope=access_scope))
if access_scope is not None
else None
),
)
@staticmethod
def _sdk_acl(allowed_cidrs, enabled=True):
"""Build a real ``stackit.ske`` ACL extension model."""
from stackit.ske.models.acl import ACL
return ACL(allowedCidrs=allowed_cidrs, enabled=enabled)
def test_sdk_model_acl_is_parsed(self):
cluster = self._sdk_cluster("locked-clst", acl=self._sdk_acl(["10.0.0.0/8"]))
assert SKEService._parse_acl(cluster) == (True, ["10.0.0.0/8"])
def test_sdk_model_without_extensions_reports_no_acl(self):
cluster = self._sdk_cluster("open-clst")
assert SKEService._parse_acl(cluster) == (False, [])
assert SKEService._parse_access_scope(cluster) is None
def test_sdk_model_access_scope_enum_is_normalized(self):
cluster = self._sdk_cluster("sna-clst", access_scope="SNA")
assert SKEService._parse_access_scope(cluster) == "SNA"
def test_list_clusters_with_sdk_response_and_model_items(self):
from stackit.ske.models.list_clusters_response import ListClustersResponse
service = object.__new__(SKEService)
service.provider = MagicMock()
service.project_id = STACKIT_PROJECT_ID
service.clusters = []
client = MagicMock()
client.list_clusters.return_value = ListClustersResponse(
items=[
self._sdk_cluster("open-clst"),
self._sdk_cluster("locked-clst", acl=self._sdk_acl(["10.0.0.0/8"])),
]
)
service._list_clusters(client, "eu01")
assert [cluster.name for cluster in service.clusters] == [
"open-clst",
"locked-clst",
]
# An object-shaped ACL must not be dropped: the restricted cluster is
# not reported as internet-exposed, and the open one still is.
assert service.clusters[0].has_public_endpoint() is True
assert service.clusters[1].acl_enabled is True
assert service.clusters[1].allowed_cidrs == ["10.0.0.0/8"]
assert service.clusters[1].has_public_endpoint() is False
def test_dict_and_sdk_model_shapes_parse_identically(self):
"""The same cluster expressed as a dict or an SDK model must agree."""
sdk_cluster = self._sdk_cluster(
"locked-clst",
acl=self._sdk_acl(["10.0.0.0/8"]),
access_scope="PUBLIC",
)
dict_cluster = {
"name": "locked-clst",
"extensions": {"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}},
"network": {"controlPlane": {"accessScope": "PUBLIC"}},
}
assert SKEService._parse_acl(sdk_cluster) == SKEService._parse_acl(dict_cluster)
assert SKEService._parse_access_scope(
sdk_cluster
) == SKEService._parse_access_scope(dict_cluster)
class Test_SKE_Cluster_Model:
"""Tests for the Cluster public-endpoint logic."""
def _cluster(self, **kwargs):
defaults = {
"id": "test-cluster",
"name": "test-cluster",
"project_id": STACKIT_PROJECT_ID,
"region": "eu01",
}
defaults.update(kwargs)
return Cluster(**defaults)
def test_private_control_plane_passes_regardless_of_acl(self):
cluster = self._cluster(access_scope="SNA", acl_enabled=False)
assert cluster.has_private_control_plane() is True
assert cluster.has_public_endpoint() is False
def test_disabled_acl_is_publicly_reachable(self):
cluster = self._cluster(acl_enabled=False)
assert cluster.has_private_control_plane() is False
assert cluster.has_public_endpoint() is True
def test_public_scope_with_restricted_acl_is_not_publicly_reachable(self):
cluster = self._cluster(
access_scope="PUBLIC", acl_enabled=True, allowed_cidrs=["10.0.0.0/8"]
)
assert cluster.unrestricted_cidrs() == []
assert cluster.has_public_endpoint() is False
@pytest.mark.parametrize("unrestricted_cidr", ["0.0.0.0/0", "::/0"])
def test_unrestricted_cidr_in_allowlist_is_publicly_reachable(
self, unrestricted_cidr
):
cluster = self._cluster(
acl_enabled=True, allowed_cidrs=["10.0.0.0/8", unrestricted_cidr]
)
assert cluster.unrestricted_cidrs() == [unrestricted_cidr]
assert cluster.has_public_endpoint() is True
def test_enabled_acl_with_empty_allowlist_is_not_publicly_reachable(self):
cluster = self._cluster(acl_enabled=True, allowed_cidrs=[])
assert cluster.has_public_endpoint() is False
def test_unset_access_scope_falls_back_to_the_acl(self):
assert (
self._cluster(
access_scope=None, acl_enabled=True, allowed_cidrs=["10.0.0.0/8"]
).has_public_endpoint()
is False
)
assert (
self._cluster(access_scope=None, acl_enabled=False).has_public_endpoint()
is True
)
@@ -276,6 +276,40 @@ class TestStackITProviderTestConnection:
(None, self.KEY_CONTENT, self.PROJECT_ID)
]
def test_connection_resource_manager_403_does_not_fail_service_discovery(
self, fake_stackit_resourcemanager
):
class Http403Error(Exception):
status = 403
fake_stackit_resourcemanager.error = Http403Error()
with patch.object(stackit_provider_module.logger, "warning") as warning:
connection = StackitProvider.test_connection(
project_id=self.PROJECT_ID,
service_account_key_path=self.KEY_PATH,
)
assert connection == Connection(is_connected=True)
warning.assert_called_once_with(
"StackIT test_connection: Resource Manager access could not be verified "
"(403). Service permissions will be checked during discovery."
)
def test_connection_resource_manager_401_still_fails(
self, fake_stackit_resourcemanager
):
class Http401Error(Exception):
status = 401
fake_stackit_resourcemanager.error = Http401Error()
with pytest.raises(StackITInvalidTokenError):
StackitProvider.test_connection(
project_id=self.PROJECT_ID,
service_account_key_path=self.KEY_PATH,
)
def test_connection_returns_error_when_raise_on_exception_is_false(
self, fake_stackit_resourcemanager
):
+510
View File
@@ -0,0 +1,510 @@
/**
* Fixture data for the Slack handlers. Shapes follow the API contract in
* `openspec/changes/add-slack-integration/design.md`.
*/
export interface SlackWorkspaceFixture {
teamId: string;
teamName: string;
botUserId: string;
/**
* Absent from the serialized configuration until a channel is chosen: the API
* omits the keys rather than sending nulls.
*/
channelId?: string;
channelName?: string;
}
export interface SlackInstallFixture {
id: string;
/** `null` until the first connection check runs. */
connected: boolean | null;
connectionLastCheckedAt: string | null;
workspace: SlackWorkspaceFixture;
}
export const SLACK_EXCHANGE_OUTCOME = {
CREATED: "created",
/** Same workspace re-installed: the existing row keeps its id. */
REINSTALLED: "reinstalled",
REFUSED_STATE: "refused-state",
SLACK_REFUSED: "slack-refused",
/** A `409` named by its `code`: one workspace per tenant. */
DIFFERENT_WORKSPACE: "different-workspace",
/**
* The three below are `2xx`: the install happened, but the answer is
* unreadable, so nothing on the failure path sees them.
*/
UNREADABLE_NO_CONTENT: "unreadable-no-content",
UNREADABLE_HTML: "unreadable-html",
UNREADABLE_NO_DATA: "unreadable-no-data",
} as const;
export type SlackExchangeOutcome =
(typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME];
export interface SlackConnectionFixture {
connected: boolean;
error: string | null;
}
/** A channel the listing endpoint offers for the picker. */
export interface SlackChannelFixture {
id: string;
name: string;
/** Private channels are listed only where `@Prowler` has been invited. */
isPrivate: boolean;
}
/**
* A refusal as the API sends one: the machine-readable reason in `code`, human
* copy in `detail`, and — for a `429` — the wait in `Retry-After`.
*/
export interface SlackRefusalFixture {
status: number;
/** Slack's stable reason. `null` for the failures classified by status. */
code: string | null;
detail: string;
/** Seconds `Retry-After` asked for; only a `429` carries one. */
retryAfterSeconds: number | null;
}
/**
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
* Revocation is best-effort: the row goes either way, and the outcome travels in
* JSON:API `meta`.
*
* One boolean is the whole of it: the API sends no reason for a revocation that
* did not happen, so modelling one would let a test prove copy the real
* deployment can never produce.
*/
export interface SlackRevocationFixture {
/**
* Slack confirmed the token no longer grants Prowler anything. `null` when the
* answer reports nothing at all — the plain `204` a deployment without a
* `destroy` override sends, which is what the UI meets today.
*/
revoked: boolean | null;
}
export interface SlackFixture {
/**
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
* `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`.
*/
appConfigured: boolean;
install: SlackInstallFixture | null;
exchangeWorkspace: SlackWorkspaceFixture;
exchangeOutcome: SlackExchangeOutcome;
connection: SlackConnectionFixture;
/** The Slack OAuth calls answer `429` with a `Retry-After`. */
rateLimited: boolean;
/**
* The shared `GET /integrations` read answers `500`, which the UI's own
* helper turns into a thrown error rather than a result.
*/
listServerError: boolean;
/** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */
authorizeUrlUnreadable: boolean;
/**
* Both Slack OAuth calls answer `502`, the contract's status for upstream and
* transport failures. Distinct from `appConfigured: false`, which is a `503`.
*/
oauthUpstreamError: boolean;
channels: SlackChannelFixture[];
/**
* Small on purpose: the default workspace spans two pages, so a UI that
* stopped at `data` instead of following `links.next` would lose channels.
*/
channelsPageSize: number;
/** Slack refused the listing outright, with the reason named in `code`. */
channelsRefusal: SlackRefusalFixture | null;
/**
* The cursor the refusal starts at. Absent, the whole read fails; a page
* size serves the first page and refuses the second — the partial read.
*/
channelsRefusalFromCursor?: number;
/**
* Slack refused the chosen channel when the `PATCH` validated it — the
* listing itself answered fine.
*/
channelSaveRefusal: SlackRefusalFixture | null;
/**
* The API refused the disconnect itself, so the row is still there
* afterwards. Distinct from `revocation`, which is about a removal that
* happened.
*/
disconnectRefusal: SlackRefusalFixture | null;
revocation: SlackRevocationFixture;
}
/**
* A UUID, as the API's ids are: it travels in the URL of every Slack call and
* the actions accept no other shape.
*/
export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
/** The scopes the channel picker and the posting need (design D2). */
export const SLACK_BOT_SCOPES = [
"chat:write",
"chat:write.public",
"channels:read",
"groups:read",
] as const;
export const SLACK_REDIRECT_URI =
"https://cloud.prowler.com/integrations/slack/callback";
/** Server-minted, single-use, bound to the tenant and user (design D5). */
export const SLACK_OAUTH_STATE = "st-2f1c9d7a";
export const SLACK_OAUTH_CODE = "slack-code-1f4a";
export const SLACK_AUTHORIZE_URL =
"https://slack.com/oauth/v2/authorize" +
"?client_id=1234567890.0987654321" +
`&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` +
`&state=${SLACK_OAUTH_STATE}` +
`&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`;
/**
* The `detail` strings the implementation sends. Human copy; the
* machine-readable reason travels in `code`, which is what the UI maps.
*/
export const SLACK_UNCONFIGURED_DETAIL =
"Slack integration is not configured or temporarily unavailable.";
export const SLACK_REFUSED_STATE_DETAIL =
"OAuth state is invalid, expired, or already consumed.";
export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid.";
export const SLACK_DIFFERENT_WORKSPACE_DETAIL =
"This tenant is already connected to a different Slack workspace.";
export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
/**
* The `code` on the contract's `502`. The UI maps no copy of its own to it, so
* the `detail` is what reaches the user.
*/
export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable";
/**
* Raised as a `ValidationError({"channel_id": ...})` that still points at
* `/data` rather than at the attribute.
*/
export const SLACK_NO_CHANNEL_DETAIL =
"This Slack integration has no channel configured.";
export const SLACK_RATE_LIMITED_DETAIL =
"Slack is rate limiting requests from Prowler.";
/**
* What a `500` from the shared `GET /integrations` read carries. Nothing here
* is for the user to act on, so the UI answers a server error in its own words.
*/
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
export const SLACK_MISSING_SCOPE_DETAIL =
"Slack refused the request: missing_scope.";
/**
* Names the raw reason, as the missing-scope wording does: what lets a test tell
* copy the UI mapped from `code` apart from an echoed `detail`.
*/
export const SLACK_TOKEN_EXPIRED_DETAIL =
"Slack refused the request: token_expired.";
/**
* The same sentence for "it is gone" and "the app was removed from it": only
* `code` separates them, which is why a client must read `code`.
*/
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
"That channel is not one Prowler can post to.";
/**
* A `403` from the shared destroy route: the role can read the integration but
* not remove it. Names no Slack reason, so the API's own wording is what the
* user is shown.
*/
export const SLACK_DISCONNECT_FORBIDDEN_DETAIL =
"You do not have permission to disconnect this integration.";
/**
* A `200` challenge page from a proxy or WAF that took the call instead of the
* API. V8 truncates the parser message for this body before the word `html`, so
* the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either.
*/
export const PROXY_CHALLENGE_PAGE = [
"<!DOCTYPE html>",
"<html><head><title>Attention Required</title></head>",
"<body><h1>Checking your browser before you proceed.</h1></body></html>",
].join("\n");
/**
* The `code` values the refusals below are named by. Wire values, spelled out
* rather than imported from the UI's own mapping: a rename on our side must
* fail these tests, not quietly agree with itself.
*/
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
export const SLACK_MISSING_SCOPE_CODE = "missing_scope";
export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found";
export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
/**
* A reason Slack really sends that the UI's mapping does not cover — the set is
* open-ended, so having no copy for one is the ordinary case.
*/
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
/**
* Two of the four dead-grant codes the contract lists. Whichever call surfaces
* one, the integration is disconnected and the only way out is connecting the
* workspace again (contract, Cross-cutting).
*/
export const SLACK_TOKEN_REVOKED_CODE = "token_revoked";
export const SLACK_TOKEN_EXPIRED_CODE = "token_expired";
export const SLACK_RETRY_AFTER_SECONDS = 30;
/** The install never granted a scope the call needs: actionable, so a `400`. */
export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_MISSING_SCOPE_CODE,
detail: SLACK_MISSING_SCOPE_DETAIL,
retryAfterSeconds: null,
};
/**
* Where this really happens is the channel listing: `conversations.list` is
* tier 2 and paginated.
*/
export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
status: 429,
code: null,
detail: SLACK_RATE_LIMITED_DETAIL,
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
};
/**
* The stored grant is no longer usable: a `400` like any other actionable
* refusal, deliberately not the `401` that would read as an expired Prowler
* session (contract, Errors).
*/
export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_TOKEN_EXPIRED_CODE,
detail: SLACK_TOKEN_EXPIRED_DETAIL,
retryAfterSeconds: null,
};
/** Slack-side or transport failure — a `502` naming no reason at all. */
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
status: 502,
code: null,
detail: SLACK_UPSTREAM_DETAIL,
retryAfterSeconds: null,
};
/** The chosen channel is archived, deleted, or was never in the workspace. */
export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_CHANNEL_NOT_FOUND_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/**
* The channel is fine, the Prowler app is simply not in it — fixed with
* `/invite @Prowler`. Identical `detail` to the refusal above, deliberately.
*/
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_NOT_IN_CHANNEL_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/** The disconnect is refused before Slack is asked anything: a `403`. */
export const SLACK_DISCONNECT_FORBIDDEN_REFUSAL: SlackRefusalFixture = {
status: 403,
code: null,
detail: SLACK_DISCONNECT_FORBIDDEN_DETAIL,
retryAfterSeconds: null,
};
/**
* Two public channels and one private the Prowler app was invited to, ordered
* so the private one lands on the second cursor page.
*/
export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0123AB",
name: "security",
isPrivate: false,
};
export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0789EF",
name: "platform",
isPrivate: false,
};
export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = {
id: "C0456CD",
name: "security-alerts",
isPrivate: true,
};
export const SLACK_CHANNELS: SlackChannelFixture[] = [
SLACK_PUBLIC_CHANNEL,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_PRIVATE_CHANNEL,
];
/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */
export const SLACK_CHANNELS_PAGE_SIZE = 2;
/**
* The first channel the picker offers, so an install seeded with it always
* points at a channel the listing really has.
*/
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
const PROWLER_HQ: SlackWorkspaceFixture = {
teamId: "T01PROWLER",
teamName: "Prowler HQ",
botUserId: "U01PROWLERBOT",
};
export const slackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture => ({
appConfigured: true,
install: null,
exchangeWorkspace: { ...PROWLER_HQ },
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED,
connection: { connected: true, error: null },
rateLimited: false,
listServerError: false,
authorizeUrlUnreadable: false,
oauthUpstreamError: false,
channels: SLACK_CHANNELS.map((channel) => ({ ...channel })),
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
channelsRefusal: null,
channelSaveRefusal: null,
disconnectRefusal: null,
revocation: { revoked: true },
...overrides,
});
/**
* A workspace approved with no destination channel yet. `connected` is `null`,
* not `true`: the check runs against the channel, so it has never run
* (design.md, "Connection state, in order").
*/
export const connectedSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixture({
install: {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...PROWLER_HQ },
},
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
...overrides,
});
const configuredInstall = (
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
): SlackInstallFixture => ({
id: SLACK_INTEGRATION_ID,
connected: true,
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
workspace: {
...PROWLER_HQ,
channelId: channel.id,
channelName: channel.name,
},
});
/**
* The same tenant with a destination channel already on record: the state a
* second visit starts from.
*/
export const slackFixtureWithDefaultChannel = (
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
/**
* The same finished setup, with a check time no parser can read: a zero date
* from a bad write or a serializer change. The contract types the attribute as
* a string and rules nothing else out.
*/
export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
connectedSlackFixture({
install: {
...configuredInstall(),
connectionLastCheckedAt: "0000-00-00T00:00:00Z",
},
});
/**
* The first cursor page is served and Slack rate limits the second: what is
* already read stays usable, the refusal only says why the list is short.
*/
export const partiallyReadSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE,
...overrides,
});
/**
* A workspace connected *and* a channel on record. Anything the API refuses
* until a channel exists (the connection check) needs this fixture.
*/
export const configuredSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
/**
* A connected tenant whose disconnect removes the row but cannot revoke at
* Slack — the outcome the user has to finish by hand in the workspace.
*/
export const revokeFailureSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
revocation: { revoked: false },
...overrides,
});
/**
* A connected tenant whose disconnect answers a plain `204` with no body: the
* row is gone and the revocation is unreported.
*/
export const unreportedRevocationSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
revocation: { revoked: null },
...overrides,
});
/**
* A connected tenant whose disconnect the API refuses: nothing is removed and
* nothing is revoked, so the workspace is still connected afterwards.
*/
export const disconnectRefusalSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
configuredSlackFixture({
disconnectRefusal: SLACK_DISCONNECT_FORBIDDEN_REFUSAL,
...overrides,
});
/**
* A connected tenant whose token has been revoked at Slack: the row still says
* connected until a check runs, and the check is what surfaces it.
*/
export const revokedTokenSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
configuredSlackFixture({
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
...overrides,
});
+372
View File
@@ -0,0 +1,372 @@
/**
* MSW handlers for the Slack integration, derived from the API contract in
* `openspec/changes/add-slack-integration/design.md` (the API itself lives in
* the cloud repository). State is per-call: an exchange creates the install the
* subsequent `GET /integrations` returns.
*
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
*/
import { http, HttpResponse } from "msw";
import {
INTEGRATIONS_SERVER_ERROR_DETAIL,
PROXY_CHALLENGE_PAGE,
SLACK_AUTHORIZE_URL,
SLACK_DIFFERENT_WORKSPACE_DETAIL,
SLACK_EXCHANGE_OUTCOME,
SLACK_INTEGRATION_ID,
SLACK_INVALID_CODE_DETAIL,
SLACK_NO_CHANNEL_DETAIL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_REFUSED_STATE_DETAIL,
SLACK_UNCONFIGURED_DETAIL,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_DETAIL,
SLACK_UPSTREAM_ERROR_CODE,
SLACK_WORKSPACE_CONFLICT_CODE,
} from "./slack.fixtures";
import type {
SlackExchangeOutcome,
SlackFixture,
SlackInstallFixture,
SlackRefusalFixture,
} from "./slack.fixtures";
const API = process.env.UI_API_BASE_URL;
const TS = "2026-08-10T09:00:00Z";
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
const CHANNEL_CURSOR_PARAM = "page[cursor]";
/**
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
* for a field-shaped `ValidationError`: the errors are about the request.
*/
const errorBody = (detail: string, status: number, code?: string) => ({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
});
/**
* Answer a fixture's refusal as the API would: its own status, its `code`
* when it names one, and `Retry-After` only where the status carries a wait.
*/
const refuse = (refusal: SlackRefusalFixture) =>
HttpResponse.json(
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
{
status: refusal.status,
...(refusal.retryAfterSeconds === null
? {}
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
},
);
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
team_id: workspace.teamId,
team_name: workspace.teamName,
bot_user_id: workspace.botUserId,
// The API omits these keys until a channel is chosen, never sending nulls.
...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
});
const integrationResource = (install: SlackInstallFixture) => ({
id: install.id,
type: "integrations",
attributes: {
inserted_at: TS,
updated_at: TS,
enabled: true,
connected: install.connected,
connection_last_checked_at: install.connectionLastCheckedAt,
integration_type: "slack",
// No credentials: the bot token is encrypted at rest and never serialized.
configuration: configuration(install.workspace),
},
links: { self: `${API}/integrations/${install.id}` },
});
const collection = (install: SlackInstallFixture | null) => ({
data: install ? [integrationResource(install)] : [],
meta: {
version: "v1",
pagination: {
page: 1,
pages: 1,
count: install ? 1 : 0,
},
},
});
const taskResource = (id: string, state: string, result: unknown) => ({
data: { id, type: "tasks", attributes: { state, result } },
});
/**
* All three are `2xx`: the first two make `response.json()` throw, the third
* parses into a body that names no resource.
*/
const unreadableExchange = (outcome: SlackExchangeOutcome): Response => {
switch (outcome) {
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
return new HttpResponse(null, { status: 204 });
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
default:
return HttpResponse.json({ meta: { version: "v1" } });
}
};
export const handlersForSlack = (fx: SlackFixture) => {
// Mutable copy: the exchange must not write through to the caller's fixture.
let install: SlackInstallFixture | null = fx.install
? { ...fx.install, workspace: { ...fx.install.workspace } }
: null;
const unconfigured = () =>
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
status: 503,
});
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
/** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
const upstreamError = () =>
HttpResponse.json(
errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE),
{ status: 502, statusText: "Bad Gateway" },
);
return [
// --- OAuth ------------------------------------------------------------
http.post(`${API}/integrations/slack/oauth/authorize-url`, () => {
if (!fx.appConfigured) return unconfigured();
if (fx.rateLimited) return rateLimited();
if (fx.oauthUpstreamError) return upstreamError();
if (fx.authorizeUrlUnreadable) {
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
}
// The URL travels in `meta`; the call creates nothing.
return HttpResponse.json({
meta: { authorize_url: SLACK_AUTHORIZE_URL },
});
}),
http.post(`${API}/integrations/slack/oauth/exchange`, () => {
if (!fx.appConfigured) return unconfigured();
if (fx.rateLimited) return rateLimited();
if (fx.oauthUpstreamError) return upstreamError();
switch (fx.exchangeOutcome) {
case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE:
return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), {
status: 400,
});
case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED:
return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), {
status: 400,
});
case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE:
return HttpResponse.json(
errorBody(
SLACK_DIFFERENT_WORKSPACE_DETAIL,
409,
SLACK_WORKSPACE_CONFLICT_CODE,
),
{ status: 409 },
);
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
// The install still happened: the API upserts before it answers.
install = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return unreadableExchange(fx.exchangeOutcome);
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
install = {
id: install?.id ?? SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return HttpResponse.json({ data: integrationResource(install) });
default:
install = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return HttpResponse.json(
{ data: integrationResource(install) },
{ status: 201 },
);
}
}),
// --- Generic integration endpoints the Slack UI reuses -----------------
http.get(`${API}/integrations`, ({ request }) => {
if (fx.listServerError) {
return HttpResponse.json(
errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500),
{ status: 500 },
);
}
const type = new URL(request.url).searchParams.get(
"filter[integration_type]",
);
// An unfiltered read would pull every type into the Slack page.
return HttpResponse.json(collection(type === "slack" ? install : null));
}),
http.post<{ id: string }>(
`${API}/integrations/:id/connection`,
({ params }) => {
// The check posts to the channel, so the API refuses until one exists.
if (!install?.workspace.channelId) {
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
status: 400,
});
}
return HttpResponse.json(
taskResource(
`${CONNECTION_TASK_PREFIX}${params.id}`,
"executing",
null,
),
{ status: 202 },
);
},
),
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
const { connected, error } = fx.connection;
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
install.connected = connected;
install.connectionLastCheckedAt = TS;
}
return HttpResponse.json(
taskResource(params.taskId, "completed", { connected, error }),
);
}),
// --- Channels ----------------------------------------------------------
http.get<{ id: string }>(
`${API}/integrations/:id/slack/channels`,
({ params, request }) => {
// The UI follows `links.next` opaquely, so the cursor's shape is this
// fixture's business alone. Read first: the page decides the refusal.
const cursor = Number(
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
);
// An endpoint-specific refusal wins over the blanket rate limiting,
// and applies from the named cursor, so a partial read is expressible.
if (
fx.channelsRefusal &&
cursor >= (fx.channelsRefusalFromCursor ?? 0)
) {
return refuse(fx.channelsRefusal);
}
if (fx.rateLimited) return rateLimited();
const nextCursor = cursor + fx.channelsPageSize;
const page = fx.channels.slice(cursor, nextCursor);
const hasMore = nextCursor < fx.channels.length;
return HttpResponse.json({
data: page.map((channel) => ({
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: channel.isPrivate },
})),
links: {
next: hasMore
? `${API}/integrations/${params.id}/slack/channels` +
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
: null,
},
});
},
),
/**
* The generic PATCH. The UI submits only `channel_id`; the name is derived
* from it here, as the API derives it from Slack (design D6).
*/
http.patch(`${API}/integrations/:id`, async ({ request }) => {
const body = (await request.json().catch(() => null)) as {
data?: { attributes?: Record<string, unknown> };
} | null;
const attributes = body?.data?.attributes ?? {};
const configurationPatch = attributes.configuration as
| { channel_id?: string }
| undefined;
const channelId = configurationPatch?.channel_id;
const channel = fx.channels.find((c) => c.id === channelId);
if (!install) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
// The API's write serializer names the attributes it will not take and
// refuses the whole save, rather than quietly dropping the extra one:
// sending `integration_type` here refused every channel save.
const refusedAttributes = Object.keys(attributes).filter(
(attribute) => attribute !== "configuration",
);
if (refusedAttributes.length > 0) {
const named = refusedAttributes
.map((attribute) => `'${attribute}'`)
.join(", ");
return HttpResponse.json(errorBody(`Invalid fields: {${named}}`, 400), {
status: 400,
});
}
// Checked before the id lookup: the picker did offer this channel, and
// Slack refused it anyway when the API validated it.
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
if (!channel) {
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
status: 400,
});
}
install.workspace.channelId = channel.id;
install.workspace.channelName = channel.name;
return HttpResponse.json({ data: integrationResource(install) });
}),
// Disconnect. Revocation at Slack is best-effort: the row is removed either
// way and the outcome travels in `meta` — or nowhere at all, in the plain
// `204` a deployment with no `destroy` override sends.
http.delete<{ id: string }>(`${API}/integrations/:id`, ({ params }) => {
if (!install || install.id !== params.id) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
// Checked first: a refused disconnect removes nothing.
if (fx.disconnectRefusal) return refuse(fx.disconnectRefusal);
install = null;
if (fx.revocation.revoked === null) {
return new HttpResponse(null, { status: 204 });
}
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
}),
];
};
+2
View File
@@ -341,6 +341,7 @@ export const testIntegrationConnection = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
@@ -387,6 +388,7 @@ export const pollConnectionTestStatus = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
+719
View File
@@ -0,0 +1,719 @@
/**
* What the Slack actions do off the DOM, which
* `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry,
* and the URLs the channel listing's cursor pagination follows.
*/
import { revalidatePath } from "next/cache";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
} from "@/lib/integrations/slack-errors";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
() => ({
/**
* The real SDK marks the exception `__sentry_captured__`, and
* `handleApiError` reads that mark to avoid reporting the same throw twice.
*/
captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => {
if (exception !== null && typeof exception === "object") {
Object.defineProperty(exception, "__sentry_captured__", {
configurable: true,
value: true,
});
}
}),
captureMessageMock: vi.fn(),
fetchMock: vi.fn(),
}),
);
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
captureMessage: captureMessageMock,
// The task poll leaves breadcrumbs on every read it makes.
addBreadcrumb: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches
// next-auth through `@/auth.config`; stubbing the session lets that copy load.
vi.mock("@/auth.config", () => ({
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: vi.fn(() =>
Promise.resolve({ Authorization: "Bearer test-token" }),
),
parseStringify: (value: unknown) => value,
}));
import {
disconnectSlackIntegration,
exchangeSlackOAuthCode,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackDefaultChannel,
} from "./slack";
/** The status the contract reserves for an upstream Slack failure. */
const UPSTREAM_STATUS = 502;
const UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
const GENERIC_SERVER_ERROR_MESSAGE =
"Server is temporarily unavailable. Please try again in a few minutes.";
const errorResponse = (status: number, detail: string, code?: string) =>
new Response(
JSON.stringify({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
}),
{ status, headers: { "content-type": "application/vnd.api+json" } },
);
const exchange = () =>
exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" });
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe.each([
{ action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" },
{ action: exchange, name: "exchangeSlackOAuthCode" },
])("$name", ({ action }) => {
it("reports an upstream Slack failure instead of only turning it into copy", async () => {
// 502 covers `internal_error`, `fatal_error`, `service_unavailable` and
// transport failures.
fetchMock.mockResolvedValue(
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"),
);
const result = await action();
// Once, not twice: `handleApiResponse` reports and throws, and the action's
// catch sees the mark.
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
tags: {
api_error: true,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
error_type: SentryErrorType.SERVER_ERROR,
status_code: String(UPSTREAM_STATUS),
},
});
expect(captureMessageMock).not.toHaveBeenCalled();
// The throw lands in the action's catch, so the page gets a result to
// render rather than a rejection that strands the callback on its spinner.
expect(result).toEqual({ error: UPSTREAM_DETAIL });
});
it("answers a 5xx the API described in HTML in Prowler's own words", async () => {
fetchMock.mockResolvedValue(
new Response("<html><body><h1>502 Bad Gateway</h1></body></html>", {
status: UPSTREAM_STATUS,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
}),
);
const result = await action();
expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE });
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
});
it.each([503, 404])(
"reports nothing for a %s: that is the feature being dark, not a fault",
async (status) => {
// 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served
// in this deployment at all.
fetchMock.mockResolvedValue(
errorResponse(status, "Slack integration is not configured."),
);
const result = await action();
// Capturing this would report the deliberate ship-dark state from every
// tenant on every page load.
expect(result).toEqual({ unavailable: true });
expect(captureExceptionMock).not.toHaveBeenCalled();
},
);
it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
errors: [{ status: "429", detail: "Slack is rate limiting." }],
}),
{
status: 429,
headers: {
"content-type": "application/vnd.api+json",
"Retry-After": "30",
},
},
),
);
const result = await action();
expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 });
expect(captureExceptionMock).not.toHaveBeenCalled();
});
});
/**
* The URL is rendered as the `Add to Slack` link's `href`, so a value the API
* got wrong must not become a redirect to somewhere that is not Slack.
*/
describe("getSlackAuthorizeUrl authorize URL", () => {
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
const CONSENT_SCREEN_URL =
"https://slack.com/oauth/v2/authorize" +
"?client_id=1234567890.0987654321&state=st-2f1c9d7a";
const authorizeUrlResponse = (authorizeUrl: unknown) =>
new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
});
it.each([
["a hostile scheme", "javascript:alert(document.domain)"],
["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"],
["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"],
["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"],
[
"another Slack path",
"https://slack.com/redirect?to=https%3A%2F%2Fevil.test",
],
["a value that is not a URL", "oauth/v2/authorize"],
])(
"refuses %s instead of offering it as the install link",
async (_label, authorizeUrl) => {
// Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen.
fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl));
// When
const result = await getSlackAuthorizeUrl();
// Then — the answer for no URL at all: nothing here is safe to link to.
expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE });
},
);
it("hands over Slack's consent screen with its query untouched", async () => {
// Given
fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL));
// When / Then
expect(await getSlackAuthorizeUrl()).toEqual({
authorizeUrl: CONSENT_SCREEN_URL,
});
});
});
/**
* The callback names the workspace and redirects on `integration` alone, so a
* `2xx` body it cannot read back as an integration must not reach it.
*/
describe("exchangeSlackOAuthCode result shape", () => {
const INTEGRATION = {
id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42",
type: "integrations",
attributes: {
integration_type: "slack",
configuration: { team_name: "Prowler HQ" },
},
};
const exchangeResponse = (data: unknown) =>
new Response(JSON.stringify({ data }), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
});
it.each<[string, unknown]>([
["an empty object", {}],
["an array", []],
["a bare string", "invalid"],
["a resource with no id", { type: "integrations", attributes: {} }],
["a resource with an empty id", { ...INTEGRATION, id: "" }],
["a resource of another type", { ...INTEGRATION, type: "tasks" }],
[
"a resource with no attributes",
{ id: INTEGRATION.id, type: "integrations" },
],
[
"another kind of integration",
{
...INTEGRATION,
attributes: { ...INTEGRATION.attributes, integration_type: "jira" },
},
],
])("cannot confirm the install from %s", async (_label, data) => {
// Given — a 2xx whose `data` is truthy but is not an integration resource.
fetchMock.mockResolvedValue(exchangeResponse(data));
// When
const result = await exchange();
// Then — the answer for a body with no `data`: the install happened, only
// its result is unknown.
expect(result).toEqual({
unconfirmed: true,
message: SLACK_UNREADABLE_RESULT_MESSAGE,
});
});
it("hands over the workspace the API upserted", async () => {
// Given
fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION));
// When / Then
expect(await exchange()).toEqual({ integration: INTEGRATION });
});
});
/** The shape the API's integration ids have, which is the only shape accepted. */
const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a";
const CHANNELS_URL =
`https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` +
"/slack/channels";
const FIRST_CHANNEL = { id: "C0123AB", name: "security" };
const SECOND_CHANNEL = { id: "C0789EF", name: "platform" };
const channelPage = (
channel: { id: string; name: string },
next: string | null,
) =>
new Response(
JSON.stringify({
data: [
{
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: false },
},
],
links: { next },
}),
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
);
const channelOption = (channel: { id: string; name: string }) => ({
id: channel.id,
name: channel.name,
is_private: false,
});
const requestedUrls = (): string[] =>
fetchMock.mock.calls.map(([url]) => String(url));
const sentBody = (callIndex = 0): unknown =>
JSON.parse(String(fetchMock.mock.calls[callIndex]?.[1]?.body));
/**
* `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot
* export: only async functions may leave one.
*/
const MAX_CHANNEL_PAGES = 20;
const channelOptions = (count: number) =>
Array.from({ length: count }, () => channelOption(FIRST_CHANNEL));
/** What a `429` carrying `Retry-After: 30` is turned into. */
const RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */
const TOKEN_EXPIRED_CODE = "token_expired";
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
const TOKEN_EXPIRED_MESSAGE =
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
describe("getSlackChannels", () => {
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
// The link is opaque (design D6), so the API may answer with the cursor
// alone; resolved against the API root it loses the listing's own path.
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([
CHANNELS_URL,
`${CHANNELS_URL}?page[cursor]=2`,
]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)],
});
});
it.each([
{
shape: "an absolute",
next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2",
},
{ shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" },
])(
"stops at $shape off-origin `next` rather than sending the tenant's token to it",
async ({ next }) => {
// `fetch` strips the tenant's `Authorization` on a redirect that leaves
// the origin, but not on a hop the UI makes itself.
fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([CHANNELS_URL]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
},
);
it("answers an unreadable page as no channels rather than parser prose", async () => {
fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ channels: [] });
expectNoParserProse(result);
});
it("says the list is short of the workspace when the page budget runs out", async () => {
// The budget exists because `conversations.list` is tier 2 and a workspace
// can outgrow it (design.md, Risks). A fresh `Response` per call: one
// instance is already consumed on its second read.
fetchMock.mockImplementation(() =>
Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({
channels: channelOptions(MAX_CHANNEL_PAGES),
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
});
it("says nothing about a short list for a workspace that just fits the budget", async () => {
let page = 0;
fetchMock.mockImplementation(() => {
page += 1;
return Promise.resolve(
channelPage(
FIRST_CHANNEL,
page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null,
),
);
});
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) });
expect(result).not.toHaveProperty("incomplete");
});
it("keeps the pages it read when a later one is refused, saying why the list stops", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
// A rate limit says nothing about the grant, so the truncation names none.
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: RATE_LIMITED_MESSAGE,
code: null,
});
});
it("names the reason a later page was refused, not only the wording", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: TOKEN_EXPIRED_MESSAGE,
code: TOKEN_EXPIRED_CODE,
});
});
it("answers a refusal on the first page as a failure, having nothing to show", async () => {
fetchMock.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
});
});
/**
* A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or
* WAF puts in front of one. The raw `SyntaxError` survives
* `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its
* `<!doctype html>` branch never matches) and would be shown verbatim.
*/
const HTML_INTERSTITIAL =
"<!DOCTYPE html><html><body><h1>Checking your browser</h1></body></html>";
const unreadableOk = (body: string) =>
new Response(body, {
status: 200,
headers: { "content-type": body ? "text/html" : "application/json" },
});
/** V8's parser wording, which no user should ever be shown. */
const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i;
const expectNoParserProse = (result: unknown) => {
const message = (result as { error?: string }).error ?? "";
expect(message).not.toMatch(PARSER_PROSE);
};
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
const saveChannel = () =>
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
/** The save as the API answers it: the channel's name derived server-side. */
const savedIntegration = () =>
new Response(
JSON.stringify({
data: {
type: "integrations",
id: SLACK_INTEGRATION_ID,
attributes: {
integration_type: "slack",
configuration: {
channel_id: FIRST_CHANNEL.id,
channel_name: FIRST_CHANNEL.name,
},
},
},
}),
{
status: 200,
headers: { "content-type": "application/vnd.api+json" },
},
);
const expectIntegrationsRevalidated = () => {
expect(vi.mocked(revalidatePath).mock.calls).toEqual([
["/integrations"],
["/integrations/slack"],
]);
};
describe("setSlackDefaultChannel", () => {
it("returns the saved integration and revalidates the pages listing it", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
const result = await saveChannel();
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
expect(result).toMatchObject({
integration: {
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
},
});
expectIntegrationsRevalidated();
});
// The write serializer names whatever it will not take and refuses the whole
// save, so a body that also carried the integration's own (immutable) type
// came back as `Invalid fields: {'integration_type'}` and recorded nothing.
it("submits the channel as the save's only attribute", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
await saveChannel();
expect(sentBody()).toEqual({
data: {
type: "integrations",
id: SLACK_INTEGRATION_ID,
attributes: { configuration: { channel_id: FIRST_CHANNEL.id } },
},
});
});
it.each([
{ shape: "empty", body: "" },
{ shape: "an HTML interstitial", body: HTML_INTERSTITIAL },
])(
"answers a $shape `200` as an unread result, not as a failed save",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(unreadableOk(body));
const result = await saveChannel();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
// The API recorded the channel before answering, so both pages refresh.
expectIntegrationsRevalidated();
},
);
// The caller reads `integration.attributes.configuration`, so a shallower
// guard lets the miss surface later as the manager's generic catch.
it.each([
{ shape: "no `data`", body: {} },
{ shape: "a null `data`", body: { data: null } },
{ shape: "a `data` with no configuration", body: { data: {} } },
])(
"answers a `200` carrying $shape as an unread result",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
}),
);
const result = await saveChannel();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
expectIntegrationsRevalidated();
},
);
});
/** The calls whose only failure path is one line of copy. */
const COPY_ONLY_ACTIONS = [
{
name: "getSlackChannels",
call: (id: string) => getSlackChannels(id),
},
{
name: "setSlackDefaultChannel",
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
},
{
name: "disconnectSlackIntegration",
call: (id: string) => disconnectSlackIntegration(id),
},
];
const rateLimitedResponse = () =>
new Response(
JSON.stringify({
errors: [{ status: "429", detail: "Slack is rate limiting." }],
}),
{
status: 429,
headers: {
"content-type": "application/vnd.api+json",
"Retry-After": "30",
},
},
);
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it("reports an upstream Slack failure and still answers in the same words", async () => {
fetchMock.mockResolvedValue(
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL),
);
const result = await call(SLACK_INTEGRATION_ID);
// Once, not twice: `handleApiResponse` reports and throws, and the action's
// catch sees the mark.
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
tags: {
api_error: true,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
error_type: SentryErrorType.SERVER_ERROR,
status_code: String(UPSTREAM_STATUS),
},
});
expect(captureMessageMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: UPSTREAM_DETAIL });
});
it.each([
{
status: 503,
why: "Slack being unavailable, not a fault",
response: () => errorResponse(503, "Slack is unavailable."),
expected: "Slack is unavailable.",
},
{
status: 429,
why: "a wait, not a fault",
response: rateLimitedResponse,
expected:
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.",
},
{
status: 400,
why: "a refusal the API meant to give",
response: () => errorResponse(400, "No default channel is set."),
expected: "No default channel is set.",
},
])("reports nothing for a $status: that is $why", async (refusal) => {
fetchMock.mockResolvedValue(refusal.response());
const result = await call(SLACK_INTEGRATION_ID);
expect(captureExceptionMock).not.toHaveBeenCalled();
expect(captureMessageMock).not.toHaveBeenCalled();
// None of these refusals names a `code`.
expect(result).toEqual({ error: refusal.expected, code: null });
});
});
/**
* The integration id is interpolated into every one of these URLs, so a
* malformed one is refused before the request is built.
*/
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it.each(["../../users", "not-a-uuid", ""])(
"asks the API nothing when the integration id is %o",
async (id) => {
const result = await call(id);
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE });
},
);
});
+566
View File
@@ -0,0 +1,566 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import {
readSlackFailure,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
slackErrorMessage,
slackRateLimitMessage,
} from "@/lib/integrations/slack-errors";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
INTEGRATION_TYPE,
type IntegrationProps,
type SlackChannelOption,
} from "@/types/integrations";
interface SlackUnavailable {
unavailable: true;
}
interface SlackRateLimited {
rateLimited: true;
retryAfterSeconds: number | null;
message: string;
}
/**
* The API accepted the exchange (`2xx`) and the UI could not read the workspace
* back: the install happened, only its result is unknown.
*/
interface SlackUnconfirmed {
unconfirmed: true;
message: string;
}
interface SlackActionError {
error: string;
/**
* The refusal's `code`, when it named one, alongside the copy. A caller reads
* it to recognise a class of failure the wording cannot be pattern-matched
* for — a Slack grant that has stopped working, which the contract allows
* from any of these calls (Cross-cutting) and is recovered from by
* reconnecting rather than by retrying.
*/
code?: string | null;
}
interface SlackAuthorizeUrl {
authorizeUrl: string;
}
export type SlackAuthorizeUrlResult =
| SlackAuthorizeUrl
| SlackUnavailable
| SlackRateLimited
| SlackActionError;
interface SlackExchangeInput {
code: string;
state: string;
}
const slackExchangeInputSchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
});
/**
* SSRF guard: the integration id is interpolated into the request URL, so only
* the shape the API's ids have reaches it.
*/
const integrationIdSchema = z.uuid();
const parseIntegrationId = (integrationId: string): string | null => {
const parsed = integrationIdSchema.safeParse(integrationId);
return parsed.success ? parsed.data : null;
};
interface SlackExchangeSuccess {
integration: IntegrationProps;
}
export type SlackExchangeResult =
| SlackExchangeSuccess
| SlackUnavailable
| SlackRateLimited
| SlackUnconfirmed
| SlackActionError;
/**
* `503`: no Slack app configured in this deployment. `404`: no Slack API at
* all. Both mean "not available here", unlike `429`/`502` which mean "not now".
*/
const isUnavailableStatus = (status: number): boolean =>
status === 503 || status === 404;
const RATE_LIMITED_STATUS = 429;
const SLACK_AUTHORIZE_HOSTNAME = "slack.com";
const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize";
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
/**
* The URL is rendered as the `Add to Slack` link's `href`, so anything that is
* not Slack's consent screen is a redirect to an origin the user did not choose.
*/
const isSlackAuthorizeUrl = (value: string): boolean => {
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
url.hostname === SLACK_AUTHORIZE_HOSTNAME &&
url.pathname === SLACK_AUTHORIZE_PATHNAME
);
} catch {
return false;
}
};
const INTEGRATIONS_RESOURCE_TYPE = "integrations";
/**
* The callback names the workspace and redirects on this value alone, so a `2xx`
* payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as
* unreadable rather than as a connected workspace. Identity too: a resource
* that is not a linkable Slack integration would be shown as the workspace
* just installed.
*/
const isIntegrationResource = (value: unknown): boolean => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const { id, type, attributes } = value as Record<string, unknown>;
if (
typeof id !== "string" ||
id === "" ||
type !== INTEGRATIONS_RESOURCE_TYPE ||
typeof attributes !== "object" ||
attributes === null ||
Array.isArray(attributes)
) {
return false;
}
return (
(attributes as Record<string, unknown>).integration_type ===
INTEGRATION_TYPE.SLACK
);
};
const failureFrom = async (
response: Response,
fallback: string,
): Promise<SlackUnavailable | SlackRateLimited | SlackActionError> => {
if (isUnavailableStatus(response.status)) return { unavailable: true };
// A 5xx (including the `502` the contract reserves for "Slack upstream
// broke") goes through the repo's 5xx handling, which reports to Sentry and
// throws, so the caller's catch answers the user. Must run before
// `readSlackFailure`: a body can only be read once.
if (response.status >= 500) await handleApiResponse(response);
const failure = await readSlackFailure(response);
if (failure.status === RATE_LIMITED_STATUS) {
return {
rateLimited: true,
retryAfterSeconds: failure.retryAfterSeconds,
message: slackRateLimitMessage(failure.retryAfterSeconds),
};
}
return { error: slackErrorMessage(failure, fallback), code: failure.code };
};
/**
* `failureFrom` flattened to one refusal, for the calls whose only outcome is
* "it did not work". Rate limiting keeps its own wording: `conversations.list`
* is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it
* names is the useful part.
*/
const refusalFrom = async (
response: Response,
fallback: string,
): Promise<SlackActionError> => {
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
// is unavailable. Must run before `readSlackFailure`: a body can only be read
// once.
if (response.status >= 500 && response.status !== 503) {
await handleApiResponse(response);
}
const failure = await readSlackFailure(response);
return {
error:
failure.status === RATE_LIMITED_STATUS
? slackRateLimitMessage(failure.retryAfterSeconds)
: slackErrorMessage(failure, fallback),
code: failure.code,
};
};
/** Mint an OAuth state and get the consent URL. Creates no integration. */
export const getSlackAuthorizeUrl =
async (): Promise<SlackAuthorizeUrlResult> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
if (!response.ok) {
// Awaited inside the `try`: a returned promise's rejection would skip
// this `catch`, and a 5xx rejects.
return await failureFrom(
response,
`Unable to start the Slack install: ${response.statusText}`,
);
}
// The URL travels in JSON:API `meta`: the call creates no resource. A
// non-JSON `2xx` reads as "no URL" instead of throwing a parser message
// the user would be shown verbatim.
const body = await response.json().catch(() => null);
const authorizeUrl = body?.meta?.authorize_url;
// A URL that is not Slack's own is no more usable than a missing one.
if (
typeof authorizeUrl !== "string" ||
!isSlackAuthorizeUrl(authorizeUrl)
) {
return { error: NO_AUTHORIZE_URL_MESSAGE };
}
return { authorizeUrl };
} catch (error) {
return handleApiError(error);
}
};
/**
* Complete the install with what Slack put in the callback URL. The API
* consumes the `state`, exchanges the single-use `code`, and upserts the
* tenant's Slack integration.
*/
export const exchangeSlackOAuthCode = async (
input: SlackExchangeInput,
): Promise<SlackExchangeResult> => {
const parsed = slackExchangeInputSchema.safeParse(input);
if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const { code, state } = parsed.data;
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "slack-oauth-exchanges",
attributes: { code, state },
},
}),
});
if (!response.ok) {
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip this
// `catch` and leave the callback on its spinner.
return await failureFrom(
response,
`Unable to connect the Slack workspace: ${response.statusText}`,
);
}
const body = await response.json().catch(() => null);
// Before the guard and on both paths: the API upserted the integration
// before answering, so a cache filled when there was none would list the
// connected workspace as missing.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
if (!isIntegrationResource(body?.data)) {
return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE };
}
return { integration: parseStringify(body.data) as IntegrationProps };
} catch (error) {
return handleApiError(error);
}
};
interface SlackChannelsSuccess {
channels: SlackChannelOption[];
/**
* Present when these channels are only part of the workspace's, carrying the
* sentence that says why: a partial read is a success, so the caller renders
* the picker *and* the reason.
*/
incomplete?: string;
/**
* The `code` of the refusal that cut the read short, when it named one. A
* grant that has stopped working refuses the second cursor page exactly as it
* refuses the first, and a caller reading only the failure path would never
* hear about it.
*/
code?: string | null;
}
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
/**
* Cursor pages followed before giving up: `conversations.list` is a tier-2,
* rate-limited Slack call (design.md, Risks), so the aggregation is bounded
* rather than open-ended.
*/
const MAX_CHANNEL_PAGES = 20;
/**
* Every channel Prowler can post to in the connected workspace — the picker's
* options.
*
* The durable primitive, not the channel stored on the integration (design D6):
* a consumer needing a per-rule channel reads the same endpoint. `links.next`
* is followed opaquely — the contract does not pin the cursor parameter naming,
* so the UI never builds one of its own. An early stop that still read
* something reports through `incomplete`, not as a failure.
*/
export const getSlackChannels = async (
integrationId: string,
): Promise<SlackChannelsResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: false });
const channels: SlackChannelOption[] = [];
const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`);
let next: string | null = listing.toString();
let incomplete: string | null = null;
try {
for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) {
const current: string = next;
const response: Response = await fetch(current, {
method: "GET",
headers,
});
if (!response.ok) {
let refusal: SlackActionError;
try {
refusal = await refusalFrom(
response,
`Unable to read the workspace's channels: ${response.statusText}`,
);
} catch (error) {
// `handleApiResponse` reported the 5xx and threw; a first-page
// failure stays a failure, but later pages keep what was read.
if (channels.length === 0) throw error;
return { channels, incomplete: SLACK_GENERIC_ERROR_MESSAGE };
}
return channels.length > 0
? { channels, incomplete: refusal.error, code: refusal.code }
: refusal;
}
// A page that is not JSON reads as no channels, rather than throwing a
// parser message the user would be shown verbatim.
const body = await response.json().catch(() => null);
for (const resource of body?.data ?? []) {
// Radix `Select.Item` refuses an empty value; one malformed resource
// would break the whole picker.
const channelId = resource?.id;
if (typeof channelId !== "string" || channelId.length === 0) continue;
channels.push({
id: channelId,
name: resource?.attributes?.name ?? "",
is_private: Boolean(resource?.attributes?.is_private),
});
}
const rawNext = body?.links?.next;
const candidate =
typeof rawNext === "string" && rawNext.length > 0
? new URL(rawNext, current)
: null;
// Resolved against the page it arrived on, so a cursor-only `next` keeps
// this listing's path. Followed only while it stays on the listing's
// origin: every page is fetched with the tenant's token, and an
// off-origin hop made here would carry it along.
if (candidate === null) {
next = null;
} else if (candidate.origin === listing.origin) {
next = candidate.toString();
} else {
next = null;
incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
}
}
// A link still waiting when the budget ran out. Checked rather than assumed
// from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was
// read to the end.
if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
return incomplete === null ? { channels } : { channels, incomplete };
} catch (error) {
return handleApiError(error);
}
};
interface SlackDefaultChannelSuccess {
integration: IntegrationProps;
}
export type SlackDefaultChannelResult =
| SlackDefaultChannelSuccess
| SlackActionError;
/**
* Record the channel Prowler posts to, on the generic integration endpoint.
*
* A Slack action despite the generic `PATCH`: `channel_not_found` and
* `not_in_channel` carry the same `detail`, so only `code` tells them apart,
* and the generic action reads `detail` alone. Only `channel_id` travels — the
* API derives `channel_name` server-side (design D6).
*/
export const setSlackDefaultChannel = async (
integrationId: string,
channelId: string,
): Promise<SlackDefaultChannelResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "integrations",
id,
// `configuration` is the only attribute the save may carry: the write
// serializer refuses whatever it does not accept, so naming the
// integration's own (immutable) type is answered with a 400,
// "Invalid fields: {'integration_type'}".
attributes: { configuration: { channel_id: channelId } },
},
}),
});
if (!response.ok) {
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip
// this `catch`.
return await refusalFrom(
response,
`Unable to save the destination channel: ${response.statusText}`,
);
}
const body = await response.json().catch(() => null);
// Before the guard and on both paths: the save happened, so a cache still
// holding the previous channel would keep showing it.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
// Guarded as deep as the caller reads: it names the saved channel from
// `attributes.configuration`.
if (!body?.data?.attributes?.configuration) {
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
}
return { integration: parseStringify(body.data) as IntegrationProps };
} catch (error) {
return handleApiError(error);
}
};
/**
* What the API reports about revoking Prowler's token at Slack: one boolean in
* `meta`, and nothing else — it sends no reason for a revocation that did not
* happen, so there is no field here to hold one.
*/
export interface SlackRevocation {
/**
* Whether Slack confirmed the token no longer grants Prowler anything, or
* `null` when the response carried no outcome. The contract says the outcome
* is always reported, so `null` means the response is wrong, not the
* revocation.
*/
revoked: boolean | null;
}
interface SlackDisconnectSuccess {
/** The integration is gone from Prowler, whatever Slack answered. */
disconnected: true;
revocation: SlackRevocation;
}
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
/**
* Disconnect the workspace: `DELETE /integrations/{id}`.
*
* The generic `deleteIntegration` cannot serve this: it discards the response
* body, and the body is the whole point. Revocation at Slack is best-effort —
* the row is removed either way and the outcome travels in JSON:API `meta` — so
* a caller has to tell "gone and revoked" from "gone, but still installed in
* Slack".
*
* A body without the field (an empty `204`, say) yields `null`, not `false`: an
* unreported outcome must not send the user off to clean up Slack, nor be shown
* as access revoked.
*/
export const disconnectSlackIntegration = async (
integrationId: string,
): Promise<SlackDisconnectResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), { method: "DELETE", headers });
if (!response.ok) {
return await refusalFrom(
response,
`Unable to disconnect the Slack workspace: ${response.statusText}`,
);
}
const body = await response.json().catch(() => ({}));
const meta = body?.meta ?? {};
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
return {
disconnected: true,
revocation: {
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
},
};
} catch (error) {
return handleApiError(error);
}
};
@@ -0,0 +1,44 @@
import { ApiKeyLinkCard } from "@/components/integrations/api-key/api-key-link-card";
import { JiraIntegrationCard } from "@/components/integrations/jira/jira-integration-card";
import { S3IntegrationCard } from "@/components/integrations/s3/s3-integration-card";
import { SecurityHubIntegrationCard } from "@/components/integrations/security-hub/security-hub-integration-card";
import { SlackIntegrationCard } from "@/components/integrations/slack/slack-integration-card";
import { SsoLinkCard } from "@/components/integrations/sso/sso-link-card";
import { isCloud } from "@/lib/shared/env";
/**
* Split out of `page.tsx` for the browser-mode tests: `ContentLayout`'s navbar
* streams async server children a client renderer can't resolve.
*/
export function IntegrationsContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard />
{/* AWS Security Hub Integration */}
<SecurityHubIntegrationCard />
{/* Jira Integration */}
<JiraIntegrationCard />
{/* Slack Integration - cloud-only API, nothing to manage self-hosted */}
{isCloud() && <SlackIntegrationCard />}
{/* SSO Configuration - redirects to Profile */}
<SsoLinkCard />
{/* API Keys - redirects to Profile */}
<ApiKeyLinkCard />
</div>
</div>
);
}
@@ -0,0 +1,40 @@
/**
* Browser-mode tests for the Slack entry in the integrations catalogue
* (`/integrations`), which is offered in Prowler Cloud only. Driven through
* `SlackIntegrationHarness` against the MSW handlers.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import { slackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack/slack-integration.harness";
describe("the integrations catalogue", () => {
it("offers Slack in Prowler Cloud, with a way to manage it", async () => {
// Given — a Prowler Cloud deployment (the fixtures' default island).
const harness = new SlackIntegrationHarness(slackFixture());
harness.mountCatalogue();
expect(await harness.listedIntegrations()).toContain("Slack");
expect(harness.offersSlackManagement()).toBe(true);
}, 30000);
it("omits Slack in a deployment that is not Prowler Cloud", async ({
seedRuntimeConfig,
}) => {
seedRuntimeConfig({ cloudEnabled: false });
const harness = new SlackIntegrationHarness(slackFixture());
harness.mountCatalogue();
const listed = await harness.listedIntegrations();
expect(listed).not.toContain("Slack");
expect(harness.offersSlackManagement()).toBe(false);
// Tripwire: the catalogue rendered, so the assertions above are Slack's
// absence rather than the page failing to load.
expect(listed).toContain("Jira");
}, 30000);
});
+3 -32
View File
@@ -1,40 +1,11 @@
import {
ApiKeyLinkCard,
JiraIntegrationCard,
S3IntegrationCard,
SecurityHubIntegrationCard,
SsoLinkCard,
} from "@/components/integrations";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { IntegrationsContent } from "./integrations-content";
export default async function Integrations() {
return (
<ContentLayout title="Integrations" icon="lucide:puzzle">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard />
{/* AWS Security Hub Integration */}
<SecurityHubIntegrationCard />
{/* Jira Integration */}
<JiraIntegrationCard />
{/* SSO Configuration - redirects to Profile */}
<SsoLinkCard />
{/* API Keys - redirects to Profile */}
<ApiKeyLinkCard />
</div>
</div>
<IntegrationsContent />
</ContentLayout>
);
}
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
export default async function SlackCallbackPage() {
if (!isCloud()) {
redirect("/");
}
return (
<ContentLayout title="Slack">
{/* `SlackCallback` reads the query string, so it needs a boundary. */}
<Suspense fallback={null}>
<SlackCallback />
</Suspense>
</ContentLayout>
);
}
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
import { SlackIntegrationContent } from "./slack-integration-content";
export default async function SlackIntegrationPage() {
// The Slack API is cloud-only, so self-hosted has nothing behind this page.
// Mirrors `/alerts`.
if (!isCloud()) {
redirect("/");
}
return (
<ContentLayout title="Slack">
<div className="flex flex-col gap-6">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect a Slack workspace so Prowler can post to one of its channels.
</p>
<SlackIntegrationContent />
</div>
</ContentLayout>
);
}
@@ -0,0 +1,246 @@
/**
* Browser-mode tests for the Slack OAuth callback
* (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`.
* MSW answers from handlers derived from the API contract in `design.md`.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
SLACK_EXCHANGE_OUTCOME,
SLACK_OAUTH_CODE,
SLACK_OAUTH_STATE,
slackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack-integration.harness";
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/**
* Callback headlines, spelled out rather than imported so a rename fails here.
* `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
* for answers that arrive after the API already upserted the integration.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack", () => {
it("completes the install and shows the connected workspace", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
expect(await harness.completedInstall()).toBe(true);
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
// The code is single-use and the exchange runs from a render (design D4):
// without the once-guard, a second call burns it and reports a failure.
expect(harness.exchangeCallCount).toBe(1);
// A completed install invalidates the cached "none connected".
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("does not report an install the API completed as failed when it answers no content", async () => {
// Given — a `204`: the API consumed the code and upserted the integration,
// then answered with no body. `response.ok` is true, so this is no refusal.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).toMatch(/Slack integration page/);
expect(reason).not.toMatch(/JSON/i);
expect(harness.offersRetry()).toBe(true);
// The `204` says the workspace is connected; the headline cannot deny it.
expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE);
// The install exists, so the cached "none connected" has to go with it.
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => {
// Given — a proxy answering `200` with a challenge page instead of JSON.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// V8's parse message truncates before the word `html`, so the shared
// HTML-shaped-error filter cannot catch this one.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/DOCTYPE/i);
expect(reason).not.toMatch(/not valid JSON/i);
}, 30000);
it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => {
// Given — a `200` carrying well-formed JSON:API with no `data` member.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/undefined/i);
expect(await harness.completedInstall()).toBe(false);
}, 30000);
it("connects nothing when the user declines in Slack, and offers to retry", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ error: "access_denied" });
expect(await harness.installFailureReason()).toMatch(
/not approved in Slack/,
);
expect(harness.offersRetry()).toBe(true);
// A declined consent carries no code, so there was nothing to exchange.
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
it("surfaces the reason when Slack refuses to complete the install", async () => {
// Given — Slack rejects the code, and the API's own wording explains it.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// A refusal Prowler has no wording of its own for falls back to the API's
// `detail`, not to a generic failure.
expect(await harness.installFailureReason()).toMatch(
/OAuth code is invalid/,
);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("surfaces a completion the API refuses, and connects nothing", async () => {
// Given — the state was minted for another session, or already consumed.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: "state-from-another-session",
});
expect(await harness.installFailureReason()).toMatch(
/state is invalid, expired, or already consumed/,
);
// The API refused before consuming anything, so nothing was created and the
// headline states that plainly.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
// Refused once, not retried into a second burnt code.
expect(harness.exchangeCallCount).toBe(1);
}, 30000);
it("says how to resolve a workspace conflict, in Prowler's own words", async () => {
// Given — this tenant already has a different workspace connected, which
// the API refuses as a 409 naming the conflict in `code`.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The copy comes from the error `code`: the API's `detail` states the
// conflict but not the way out of it.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/already connected to a different Slack workspace/);
expect(reason).toMatch(/Disconnect it before connecting another/);
expect(reason).not.toMatch(/tenant/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("tells the user when to come back if Slack is rate limiting the install", async () => {
// Given — Slack answers 429 with a Retry-After.
const harness = new SlackIntegrationHarness(
slackFixture({ rateLimited: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/rate limiting/);
expect(reason).toMatch(/about 30 seconds/);
expect(reason).not.toMatch(/not available in this environment/);
// A 429 refuses the exchange outright, so nothing was connected: the plain
// headline, unlike the unreadable `2xx` that arrives after the upsert.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => {
// Given — the completion answers `502`, the contract's status for a Slack
// upstream failure. The shared 5xx handling throws, so the callback only
// renders this if the action answers that rejection itself.
const harness = new SlackIntegrationHarness(
slackFixture({ oauthUpstreamError: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The API refused, so nothing was created: not the "could not confirm" the
// page falls back to when the action never answers at all.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/temporarily unavailable/);
expect(reason).not.toMatch(/could not confirm/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("does not attempt an exchange when the completion carries no state", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ code: SLACK_OAUTH_CODE });
// Refused before the API is ever asked, so no code is spent.
expect(await harness.installFailureReason()).toMatch(/incomplete response/);
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
});
@@ -0,0 +1,61 @@
import { getIntegrations } from "@/actions/integrations/integrations";
import { getSlackAuthorizeUrl } from "@/actions/integrations/slack";
import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager";
import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
/**
* `getIntegrations` throws a `>= 500` answer past its own catch, which covers
* only transport. Uncaught it trips the route's error boundary and replaces a
* page that could still offer the install, so report it as `{ error }` and take
* the page's one error path.
*/
const readSlackIntegrations = async (searchParams: URLSearchParams) => {
try {
return await getIntegrations(searchParams);
} catch {
// The thrown message can carry the server's own wording; `handleApiResponse`
// already reported it to Sentry.
return { error: GENERIC_SERVER_ERROR_MESSAGE };
}
};
/**
* Split out of `page.tsx` so the browser-mode tests can render it without the
* surrounding `ContentLayout`.
*/
export async function SlackIntegrationContent() {
const searchParams = new URLSearchParams();
searchParams.set("filter[integration_type]", INTEGRATION_TYPE.SLACK);
// One workspace per tenant, so one row is the whole result set.
searchParams.set("page[size]", "1");
const integrations = await readSlackIntegrations(searchParams);
const loadError =
integrations && "error" in integrations
? (integrations.error as string)
: null;
const integration: IntegrationProps | null =
(integrations?.data?.[0] as IntegrationProps | undefined) ?? null;
const authorize = integration ? null : await getSlackAuthorizeUrl();
return (
<SlackIntegrationManager
integration={integration}
authorizeUrl={
authorize && "authorizeUrl" in authorize ? authorize.authorizeUrl : null
}
unavailable={Boolean(authorize && "unavailable" in authorize)}
// Rate limited is not unavailable: the install is still on offer, it just
// cannot be started yet.
rateLimitMessage={
authorize && "rateLimited" in authorize ? authorize.message : null
}
loadError={
loadError ??
(authorize && "error" in authorize ? authorize.error : null)
}
/>
);
}
@@ -0,0 +1,885 @@
/**
* Page-level test harness for the Slack integration (Vitest Browser Mode).
*
* A client renderer cannot render an async server component, so the component is
* called and the element it returns is what gets rendered.
*/
import { revalidatePath } from "next/cache";
import { createElement } from "react";
import { vi } from "vitest";
import { BrowserHarness } from "@/__tests__/browser-harness";
import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import { setSlackDefaultChannel } from "@/actions/integrations/slack";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { IntegrationsContent } from "../integrations-content";
import { SlackIntegrationContent } from "./slack-integration-content";
export const CONNECTION_OUTCOME = {
SUCCESS: "success",
FAILURE: "failure",
} as const;
export type ConnectionOutcome =
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
export const REVOCATION_OUTCOME = {
REVOKED: "revoked",
NOT_REVOKED: "not-revoked",
/** The answer said nothing either way, so the page claims neither. */
UNREPORTED: "unreported",
} as const;
export type RevocationOutcome =
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
/** The alert shown when Slack never confirmed the revocation. */
const REVOCATION_NOTICE = /revocation/i;
/** The alert shown when Slack has stopped accepting the credential. */
const REVOKED_CREDENTIAL_NOTICE = /no longer accepts Prowler's access/;
interface CallbackParams {
code?: string;
state?: string;
/** Slack's own refusal code, e.g. `access_denied`. */
error?: string;
}
/** What a picker search leaves on offer. */
interface ChannelSearch {
/** Names still offered once the filter landed, in the order offered. */
offered: string[];
/** The picker's no-match note; null while any channel is still offered. */
emptyNote: string | null;
}
export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
get exchangeCallCount(): number {
return this.countRequests("POST", "/slack/oauth/exchange");
}
get authorizeUrlCallCount(): number {
return this.countRequests("POST", "/slack/oauth/authorize-url");
}
/** Paths the actions asked Next to refresh (`next/cache` is stubbed in this lane). */
get revalidatedPaths(): string[] {
return vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
}
// --- Mounting -----------------------------------------------------------
private wireHandlers(): void {
// The stub is module-level and shared, so clearing it here is what makes
// `revalidatedPaths` mean "since this mount".
vi.mocked(revalidatePath).mockClear();
worker.use(...handlersForSlack(this.fixture));
this.trackRequests(worker);
}
async mount(): Promise<void> {
window.history.replaceState(null, "", "/integrations/slack");
this.wireHandlers();
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
private mounted: ReturnType<typeof render> | null = null;
/**
* Open the management page again, the way a later visit does — the handlers in
* place keep serving what the previous visit left behind. Unmounts the previous
* render first: two live copies would make every assertion ambiguous.
*/
async revisit(): Promise<void> {
(await this.mounted)?.unmount();
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
await this.mounted;
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
/**
* Refresh the page's server data under the open card, as `revalidatePath` does
* after an action: new props, no unmount, so React state survives — unlike
* `revisit()`, which re-seeds everything from scratch.
*/
async refreshPageData(): Promise<void> {
const rendered = await this.mounted;
if (!rendered) {
throw new Error("refreshPageData: the page is not mounted");
}
await rendered.rerender(await SlackIntegrationContent());
}
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
const params = new URLSearchParams();
if (code) params.set("code", code);
if (state) params.set("state", state);
if (error) params.set("error", error);
window.history.replaceState(
null,
"",
`/integrations/slack/callback?${params.toString()}`,
);
this.wireHandlers();
render(createElement(SlackCallback));
}
/** Mount the integrations catalogue. No handlers: every card there is static. */
mountCatalogue(): void {
window.history.replaceState(null, "", "/integrations");
render(createElement(IntegrationsContent));
}
// --- The integrations catalogue ------------------------------------------
async listedIntegrations(): Promise<string[]> {
const headings = await this.waitFor(
() => {
const found = Array.from(
this.container.querySelectorAll<HTMLElement>("h4"),
);
return found.length > 0 ? found : null;
},
5000,
"the integrations catalogue",
);
return headings.map((heading) => (heading.textContent ?? "").trim());
}
offersSlackManagement(): boolean {
return this.q('a[href="/integrations/slack"]') !== null;
}
// --- Starting the install -----------------------------------------------
private connectLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
/Add to Slack/.test(anchor.textContent ?? ""),
) ?? null
);
}
async authorizeUrl(): Promise<string> {
const link = await this.waitFor(
() => this.connectLink(),
5000,
"the Add to Slack link",
);
return link.href;
}
/**
* Clicks the install affordance and reports where it points. The default
* action is cancelled: following the link navigates the test frame off the app.
*/
async connect(): Promise<string> {
const link = await this.waitFor(
() => this.connectLink(),
5000,
"the Add to Slack link",
);
let destination = "";
const intercept = (event: MouseEvent) => {
event.preventDefault();
destination = link.href;
};
link.addEventListener("click", intercept);
try {
await this.clickElement(link, { fallbackToDomClick: true });
} finally {
link.removeEventListener("click", intercept);
}
return destination;
}
offersInstall(): boolean {
return this.connectLink() !== null;
}
async waitForUnavailable(): Promise<void> {
await this.waitForText(/Slack is not available in this environment yet/);
}
saysUnavailable(): boolean {
return this.containsText(/Slack is not available in this environment yet/);
}
saysLoadFailed(): boolean {
return this.containsText(/Could not load your Slack integration/);
}
async rateLimitNotice(): Promise<string> {
await this.waitForText(/Slack is busy right now/, 10000);
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the rate limit notice",
);
return (description.textContent ?? "").trim();
}
async loadErrorNotice(): Promise<string> {
await this.waitForText(/Could not load your Slack integration/, 10000);
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the load error notice",
);
return (description.textContent ?? "").trim();
}
// --- Connected state ----------------------------------------------------
/**
* Read from the heading element, not the page text: in `textContent`
* "Connected to <workspace>" runs straight into the copy that follows it.
*/
async connectedWorkspaceName(): Promise<string> {
const heading = await this.waitFor(
() => this.deepestElementMatching(/^Connected to \S/),
5000,
"the connected workspace name",
);
return (heading.textContent ?? "").trim().replace(/^Connected to /, "");
}
/** Last match in document order: every ancestor of a match matches too. */
private deepestElementMatching(pattern: RegExp): HTMLElement | null {
return (
Array.from(this.container.querySelectorAll<HTMLElement>("*"))
.reverse()
.find((element) => pattern.test((element.textContent ?? "").trim())) ??
null
);
}
/**
* Keyed on the badge's state attribute, not its copy: the heading beside it
* also starts "Connected to …".
*/
async connectionBadge(): Promise<string> {
const badge = await this.waitFor(
() => this.q("[data-connection-status]"),
5000,
"the connection badge",
);
return (badge.textContent ?? "").trim();
}
async offersConnectionTest(): Promise<boolean> {
const button = await this.waitFor(
() => this.buttonByText(/Test connection/),
5000,
"the Test connection button",
);
return !button.disabled;
}
/**
* Why the check cannot run, read from the copy the button points at: a reason
* found anywhere else on the page never reaches whoever sees the control.
*/
connectionCheckBlockedReason(): string | null {
const button = this.buttonByText(/Test connection/);
const describedBy = button?.getAttribute("aria-describedby");
if (!describedBy) return null;
const reason = this.container.querySelector<HTMLElement>(`#${describedBy}`);
return reason ? (reason.textContent ?? "").trim() : null;
}
/**
* The "last checked" line as rendered, or null when the page shows none —
* which is what a workspace whose connection was never checked shows.
*/
lastCheckedLine(): string | null {
const line = Array.from(
this.container.querySelectorAll<HTMLElement>("p"),
).find((p) => /^Last checked:/.test((p.textContent ?? "").trim()));
return line ? (line.textContent ?? "").trim() : null;
}
get connectionCheckCallCount(): number {
return this.countRequests("POST", "/connection");
}
/** The outcome of a check under way, started by the button or by a save. */
async connectionOutcome(): Promise<ConnectionOutcome> {
return this.waitFor(
() => {
if (this.containsText(/Connection test successful/)) {
return CONNECTION_OUTCOME.SUCCESS;
}
if (this.containsText(/Connection test failed/)) {
return CONNECTION_OUTCOME.FAILURE;
}
return null;
},
15000,
"the connection test outcome",
);
}
async testConnection(): Promise<ConnectionOutcome> {
await this.clickButton(/Test connection/);
return this.connectionOutcome();
}
// --- Returning from Slack -----------------------------------------------
/**
* The one element every non-success outcome renders. Keyed on it rather than
* the alert title, which is not the same claim on every outcome.
*/
private backLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find(
(anchor) =>
anchor.getAttribute("href") === "/integrations/slack" &&
/Back to Slack integration/.test(anchor.textContent ?? ""),
) ?? null
);
}
async completedInstall(): Promise<boolean> {
const outcome = await this.waitFor(
() => this.containsText(/Connected to /) || this.backLink() !== null,
10000,
"the callback outcome",
);
return outcome && this.containsText(/Connected to /);
}
async installFailureReason(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the failure reason",
);
return (description.textContent ?? "").trim();
}
async installFailureTitle(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
const title = await this.waitFor(
() => this.q('[data-slot="alert-title"]'),
5000,
"the failure title",
);
return (title.textContent ?? "").trim();
}
offersRetry(): boolean {
return this.backLink() !== null || this.offersInstall();
}
// --- Choosing a destination channel --------------------------------------
/** Channel reads issued — one per cursor page the UI followed. */
get channelListCallCount(): number {
return this.countRequests("GET", "/slack/channels");
}
/**
* Wait for the channel read every connected mount starts, counting from the
* reads already issued: one still in flight when the test ends lands in the
* middle of the next, against a harness that never asked for it.
*/
private async waitForChannelsRead(readsBefore: number): Promise<void> {
await this.waitFor(
() => {
const refresh = this.buttonByText(/Refresh channels/);
return this.channelListCallCount > readsBefore &&
refresh !== null &&
!refresh.disabled
? true
: null;
},
15000,
"the workspace's channels to be read",
);
}
/**
* Open the picker and hand back its options. A re-render landing mid-gesture
* makes Radix drop the open state, so re-open from the keyboard when nothing
* mounted at all.
*/
private async openChannelPicker(): Promise<HTMLElement[]> {
const mounted = (): HTMLElement[] | null => {
const options = Array.from(
document.querySelectorAll<HTMLElement>('[role="option"]'),
);
return options.length > 0 ? options : null;
};
const alreadyOpen = mounted();
if (alreadyOpen) return alreadyOpen;
const trigger = await this.waitFor<HTMLElement>(
() => this.q("#slack-channel"),
10000,
"the channel picker",
);
await this.clickElement(trigger, { fallbackToDomClick: true });
let options = await this.waitForOrNull(
mounted,
2000,
"the channel options",
);
if (!options) {
await this.user.keyboard("{Enter}");
options = await this.waitForOrNull(mounted, 8000, "the channel options");
}
if (!options) {
throw new Error("openChannelPicker: the channel picker offered nothing");
}
return options;
}
private async closeChannelPicker(): Promise<void> {
await this.user.keyboard("{Escape}");
await this.waitForTransition();
}
/**
* Re-read the workspace's channels, the way a user does after inviting
* `@Prowler` to one in Slack. Waits for the read to have settled, not for the
* click alone.
*/
async refreshChannels(): Promise<void> {
const readsBefore = this.channelListCallCount;
await this.clickButton(/Refresh channels/);
await this.waitFor(
() => {
const button = this.buttonByText(/Refresh channels/);
return (
this.channelListCallCount > readsBefore &&
button !== null &&
!button.disabled
);
},
15000,
"the workspace's channels to be read again",
);
}
/** The channels the workspace offers, in the order the picker lists them. */
async channelOptions(): Promise<string[]> {
const options = await this.openChannelPicker();
const names = options.map(
(option) => option.getAttribute("data-channel") ?? "",
);
await this.closeChannelPicker();
return names;
}
/**
* Whether the channel offered under `name` is presented as private — read
* from the marker the user sees, not from how the option is wired up.
*/
async isChannelShownAsPrivate(name: string): Promise<boolean> {
const options = await this.openChannelPicker();
const option = options.find(
(element) => element.getAttribute("data-channel") === name,
);
await this.closeChannelPicker();
return /Private/.test(option?.textContent ?? "");
}
/**
* Open the picker, type `query` into its search, and hand back what the
* filter leaves on offer. The search dies with the popover, so each call
* starts from the full list.
*/
async searchChannels(query: string): Promise<ChannelSearch> {
const all = await this.openChannelPicker();
const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
if (!input) {
throw new Error("searchChannels: the open picker has no search field");
}
await this.user.fill(input, query);
// The filter lands a render after the last keystroke: the offered set
// shrinks, or the no-match note shows. A query that matches everything
// would never settle — the tests only narrow.
await this.waitFor(
() =>
document.querySelectorAll('[role="option"]').length !== all.length ||
document.querySelector("[cmdk-empty]") !== null ||
null,
5000,
"the search to narrow the channels",
);
const offered = Array.from(
document.querySelectorAll<HTMLElement>('[role="option"]'),
).map((option) => option.getAttribute("data-channel") ?? "");
const emptyNote =
document.querySelector<HTMLElement>("[cmdk-empty]")?.textContent ?? null;
await this.closeChannelPicker();
return { offered, emptyNote };
}
private async pickAndSave(name: string): Promise<void> {
const options = await this.openChannelPicker();
const option = options.find(
(element) => element.getAttribute("data-channel") === name,
);
if (!option) {
throw new Error(`pickAndSave: no channel named "${name}" is offered`);
}
await this.user.click(option);
await this.waitForTransition();
await this.clickButton(/Save channel/);
}
/** Pick a channel, save it, and wait for it to be recorded as the destination. */
async chooseChannel(name: string): Promise<void> {
await this.pickAndSave(name);
await this.waitFor(
() => this.defaultChannelName() === name,
15000,
`#${name} to be recorded as the destination`,
);
}
/**
* Record a different destination away from this page — a second tab, or someone
* else in the tenant. Goes through the same call the page makes, leaving this
* page's own copy of it untouched.
*/
async channelRecordedElsewhere(name: string): Promise<void> {
const channel = this.fixture.channels.find((c) => c.name === name);
if (!channel) {
throw new Error(
`channelRecordedElsewhere: no channel named "${name}" is offered`,
);
}
const integrationId = this.fixture.install?.id;
if (!integrationId) {
throw new Error("channelRecordedElsewhere: no workspace is connected");
}
const result = await setSlackDefaultChannel(integrationId, channel.id);
if ("error" in result) {
throw new Error(`channelRecordedElsewhere: ${result.error}`);
}
}
/** Whether the picked channel can be saved — false when there is nothing new to save. */
offersChannelSave(): boolean {
const button = this.buttonByText(/Save channel/);
return button !== null && !button.disabled;
}
/**
* Try to save a channel the API refuses and hand back what the user is told. A
* save that succeeds fails the test rather than timing out.
*/
async refusedChannelSave(name: string): Promise<string> {
await this.pickAndSave(name);
return this.waitFor(
() => {
if (this.defaultChannelName() === name) {
throw new Error(
`refusedChannelSave: #${name} was recorded, not refused`,
);
}
return this.toastText(/Could not save the destination channel/);
},
15000,
"the refused channel save",
);
}
/**
* The text of the toast matching `pattern` — title and message together. Radix
* portals each toast into its viewport as an `<li>`, outside the page's markup.
*/
private toastText(pattern: RegExp): string | null {
const toast = Array.from(
document.querySelectorAll<HTMLElement>("ol li"),
).find((element) => pattern.test(element.textContent ?? ""));
return toast ? (toast.textContent ?? "").replace(/\s+/g, " ").trim() : null;
}
private defaultChannelName(): string | null {
return (
/Prowler posts to #(\S+?)\./.exec(
this.container.textContent ?? "",
)?.[1] ?? null
);
}
/** The channel recorded as the integration's destination, if any. */
async defaultChannel(): Promise<string | null> {
const settled = await this.waitFor(
() =>
this.defaultChannelName() ??
(this.containsText(/No destination channel recorded yet/)
? NO_DEFAULT_CHANNEL
: null),
10000,
"the recorded destination channel",
);
return settled === NO_DEFAULT_CHANNEL ? null : settled;
}
/** What the user is told when the workspace exposes no channel at all. */
async channelPickerMessage(): Promise<string> {
const alert = await this.waitFor(
() =>
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((element) =>
/No channels available yet|Could not read the workspace/.test(
element.textContent ?? "",
),
),
10000,
"the channel picker's message",
);
return (alert.textContent ?? "").replace(/\s+/g, " ").trim();
}
/**
* What the user is told about a list short of the workspace, shown beside a
* picker that still works — unlike `channelPickerMessage()`, which replaces it.
*/
partialListNotice(): string | null {
const notice = this.q("[data-channels-notice]");
return notice
? (notice.textContent ?? "").replace(/\s+/g, " ").trim()
: null;
}
/** Whether the picker was replaced by the "could not read them" alert. */
saysChannelsUnreadable(): boolean {
return this.containsText(/Could not read the workspace/);
}
/** The invite copy that says how to make a private channel appear. */
channelInviteHint(): string | null {
const hint = Array.from(
this.container.querySelectorAll<HTMLElement>("p"),
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
return hint ? (hint.textContent ?? "").trim() : null;
}
// --- Disconnecting ------------------------------------------------------
get disconnectCallCount(): number {
return this.countRequests("DELETE", "/integrations/");
}
/**
* Disconnects the workspace, confirming the way a user has to, and reports
* what the page says about the revocation. The outcomes are mutually
* exclusive, so asking for one also checks the others are absent.
*
* The revoked and unreported outcomes share a toast title, so each is read
* from its own description: a title match would agree with either.
*/
async disconnect(): Promise<RevocationOutcome> {
await this.openDisconnectConfirmation();
return this.confirmDisconnect();
}
/**
* Opens the confirmation and reports what it asks the user to accept, before
* anything is accepted.
*/
async openDisconnectConfirmation(): Promise<string> {
// The dialog's own button carries the noun too, hence the exact match on
// the card's action.
await this.clickButton(/^\s*Disconnect\s*$/);
const description = await this.waitFor(
() => this.q('[data-slot="dialog-description"]'),
10000,
"the disconnect confirmation",
);
return (description.textContent ?? "").replace(/\s+/g, " ").trim();
}
/** Accepts the open confirmation and reports the revocation outcome. */
async confirmDisconnect(): Promise<RevocationOutcome> {
await this.clickButton(/Disconnect workspace/);
return this.waitFor(
() => {
const outcomes = [
this.alertMatching(REVOCATION_NOTICE)
? REVOCATION_OUTCOME.NOT_REVOKED
: null,
this.containsText(/has been revoked/)
? REVOCATION_OUTCOME.REVOKED
: null,
this.containsText(/is no longer connected to Prowler/)
? REVOCATION_OUTCOME.UNREPORTED
: null,
].filter((outcome): outcome is RevocationOutcome => outcome !== null);
if (outcomes.length > 1) {
throw new Error(
`confirmDisconnect: the page shows ${outcomes.join(" and ")} at once`,
);
}
return outcomes[0] ?? null;
},
15000,
"the disconnect outcome",
);
}
/**
* Try a disconnect the API refuses and hand back what the user is told. One
* that goes through fails the test rather than timing out.
*/
async refusedDisconnect(): Promise<string> {
await this.openDisconnectConfirmation();
await this.clickButton(/Disconnect workspace/);
return this.waitFor(
() => {
if (this.containsText(/No workspace connected/)) {
throw new Error(
"refusedDisconnect: the workspace was disconnected, not refused",
);
}
return this.toastText(/Disconnect failed/);
},
15000,
"the refused disconnect",
);
}
/**
* Whether the page is back to offering an install with no workspace
* connected. The consent URL is minted after the disconnect, so the install
* affordance appears a beat after the copy does.
*/
async returnedToUnconnectedState(): Promise<boolean> {
await this.waitForText(/No workspace connected/, 10000);
return (
(await this.waitForOrNull(
() => this.offersInstall(),
5000,
"the install to be offered again",
)) ?? false
);
}
/** Whether the page is asking the user to remove the access in Slack. */
showsRevocationNotice(): boolean {
return this.alertMatching(REVOCATION_NOTICE) !== null;
}
/**
* What the user is told when the row was removed but Slack never confirmed
* the revocation.
*/
async revocationNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(REVOCATION_NOTICE),
10000,
"the revocation notice",
);
return (notice.textContent ?? "").trim();
}
// --- A credential Slack no longer accepts --------------------------------
/** What the user is told when Slack has stopped accepting the credential. */
async revokedCredentialNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(REVOKED_CREDENTIAL_NOTICE),
10000,
"the revoked-credential notice",
);
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
}
/** Whether the page is saying Slack has stopped accepting the credential. */
showsRevokedCredentialNotice(): boolean {
return this.alertMatching(REVOKED_CREDENTIAL_NOTICE) !== null;
}
private reconnectLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
/Reconnect to Slack/.test(anchor.textContent ?? ""),
) ?? null
);
}
/** Whether the page offers to approve Prowler in the workspace again. */
offersReconnect(): boolean {
return this.reconnectLink() !== null;
}
/**
* Waits, unlike `offersReconnect`: the affordance needs a consent URL the
* page mints only once it knows it needs one, so it lands a beat after the
* notice that explains it.
*/
async waitForReconnect(): Promise<void> {
await this.waitFor(() => this.reconnectLink(), 10000, "the reconnect link");
}
/** The consent URL the reconnect affordance points at, once it is offered. */
async reconnectUrl(): Promise<string> {
const link = await this.waitFor(
() => this.reconnectLink(),
10000,
"the reconnect link",
);
return link.href;
}
/** The alert whose text matches, of however many the page is showing. */
private alertMatching(pattern: RegExp): HTMLElement | null {
return (
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
);
}
}
@@ -0,0 +1,822 @@
/**
* Browser-mode tests for the Slack integration page (`/integrations/slack`),
* driven through `SlackIntegrationHarness`. MSW answers from handlers derived
* from the API contract in `design.md`. The OAuth callback is its own route,
* covered in `slack-callback-page.integration.test.tsx`.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
configuredSlackFixture,
connectedSlackFixture,
disconnectRefusalSlackFixture,
INTEGRATIONS_SERVER_ERROR_DETAIL,
partiallyReadSlackFixture,
revokedTokenSlackFixture,
revokeFailureSlackFixture,
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
SLACK_DISCONNECT_FORBIDDEN_DETAIL,
SLACK_MISSING_SCOPE_CODE,
SLACK_MISSING_SCOPE_REFUSAL,
SLACK_NOT_IN_CHANNEL_CODE,
SLACK_NOT_IN_CHANNEL_REFUSAL,
SLACK_PRIVATE_CHANNEL,
SLACK_PUBLIC_CHANNEL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_TOKEN_EXPIRED_CODE,
SLACK_TOKEN_EXPIRED_REFUSAL,
SLACK_TOKEN_REVOKED_CODE,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_REFUSAL,
slackFixture,
slackFixtureWithDefaultChannel,
unreadableCheckTimeSlackFixture,
unreportedRevocationSlackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import {
CONNECTION_OUTCOME,
REVOCATION_OUTCOME,
SlackIntegrationHarness,
} from "./slack-integration.harness";
/** The shape the channel save is asserted against — only the id travels. */
interface PatchIntegrationBody {
data: PatchIntegrationData;
}
interface PatchIntegrationData {
attributes: PatchIntegrationAttributes;
}
interface PatchIntegrationAttributes {
configuration: PatchChannelConfiguration;
}
interface PatchChannelConfiguration {
channel_id: string;
}
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/** The only scopes Prowler asks a workspace for (design D2). */
const REQUIRED_SCOPES = [
"chat:write",
"chat:write.public",
"channels:read",
"groups:read",
];
describe("starting the install", () => {
it("sends the user to Slack's consent screen for the access Prowler needs", async () => {
// Given — a tenant with no workspace connected yet.
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mount();
const consentScreen = new URL(await harness.connect());
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
"https://slack.com/oauth/v2/authorize",
);
const scopes = (consentScreen.searchParams.get("scope") ?? "").split(",");
expect(scopes).toHaveLength(REQUIRED_SCOPES.length);
expect(scopes).toEqual(expect.arrayContaining(REQUIRED_SCOPES));
// The state is server-minted, binding this install to the session
// (design D5).
expect(consentScreen.searchParams.get("state")).toBeTruthy();
}, 30000);
it("says so when the deployment has no Slack app, instead of offering an install", async () => {
// Given — no SLACK_CLIENT_ID/SECRET/REDIRECT_URI, which the API answers
// with a 503.
const harness = new SlackIntegrationHarness(
slackFixture({ appConfigured: false }),
);
await harness.mount();
// The read itself succeeded: an empty collection is what a deployment with
// no Slack app has, so nothing claims it failed.
await harness.waitForUnavailable();
expect(harness.offersInstall()).toBe(false);
expect(harness.saysLoadFailed()).toBe(false);
}, 30000);
it("still says the read failed when the deployment also has no Slack app", async () => {
// Given — both states, which coincide during rollout and rollback
// (design.md, Migration Plan §2-3 and §5).
const harness = new SlackIntegrationHarness(
slackFixture({ appConfigured: false, listServerError: true }),
);
await harness.mount();
// Both notices: the read's is the actionable half (a retry may still show a
// workspace this tenant has connected).
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
expect(harness.saysUnavailable()).toBe(true);
// The install is still not on offer: there is no Slack app to install into.
expect(harness.offersInstall()).toBe(false);
}, 30000);
it("says Slack is busy, not that the deployment has no Slack app, when it is rate limiting", async () => {
// Given — the app is configured; Slack rate limits (429) the call that
// mints the consent URL.
const harness = new SlackIntegrationHarness(
slackFixture({ rateLimited: true }),
);
await harness.mount();
expect(await harness.rateLimitNotice()).toMatch(/about 30 seconds/);
expect(harness.saysUnavailable()).toBe(false);
}, 30000);
it("keeps the page usable when reading the install fails on the server", async () => {
// Given — the shared `GET /integrations` read answers 500. The action
// throws instead of returning a result, so the page has to catch it:
// uncaught, the route's error boundary replaces the Slack page.
const harness = new SlackIntegrationHarness(
slackFixture({ listServerError: true }),
);
await harness.mount();
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
// The install stays on offer: one read failed, the Slack app is fine.
expect(harness.offersInstall()).toBe(true);
}, 30000);
it("keeps the page usable when Slack's own side is broken upstream", async () => {
// Given — the `502` the contract reserves for a Slack upstream failure.
// The UI's shared 5xx handling throws, so this is the page's other
// rejection path.
const harness = new SlackIntegrationHarness(
slackFixture({ oauthUpstreamError: true }),
);
await harness.mount();
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
// 502 is not 503: the app is configured, Slack is down.
expect(harness.saysUnavailable()).toBe(false);
}, 30000);
it("names the missing consent URL when a proxy answers that call with an HTML page", async () => {
// Given — a 200 carrying a challenge page instead of JSON. Nothing refused
// the call, so the action reaches its success path with no URL.
const harness = new SlackIntegrationHarness(
slackFixture({ authorizeUrlUnreadable: true }),
);
await harness.mount();
// V8 truncates the parse message to `"<!DOCTYPE "`, before the word `html`,
// so the UI's HTML-shaped-error filter can never match it.
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/did not return an authorization URL/);
expect(notice).not.toMatch(/DOCTYPE/i);
expect(notice).not.toMatch(/not valid JSON/i);
}, 30000);
});
describe("a connected workspace", () => {
it("identifies the workspace and reports the connection as healthy", async () => {
// Given — a finished setup: workspace approved and a destination channel
// recorded, which the API requires before it will check a connection.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
expect(await harness.offersConnectionTest()).toBe(true);
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
// One workspace per tenant (design D10): no second install on offer, and no
// consent URL minted for a page that would never use it.
expect(harness.offersInstall()).toBe(false);
expect(harness.authorizeUrlCallCount).toBe(0);
}, 30000);
it("still identifies the workspace before a destination channel is chosen", async () => {
// Given — the state the OAuth exchange leaves behind.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// The configuration carries no channel keys at all, which is "nothing
// chosen yet", not a broken install.
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(harness.offersInstall()).toBe(false);
}, 30000);
it("reports the connection as never checked, not as broken, before the first check", async () => {
// Given — the state the OAuth exchange leaves behind: `connected` is null,
// neither true nor false (design.md, "Connection state, in order").
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
const badge = await harness.connectionBadge();
expect(badge).toBe("Not checked yet");
expect(badge).not.toMatch(/Disconnected/);
}, 30000);
it("keeps the page usable when the recorded check time is one no parser can read", async () => {
// Given — a finished setup whose `connection_last_checked_at` is a zero
// date. `date-fns` throws a RangeError on it, which would replace the whole
// page with the route's error boundary.
const harness = new SlackIntegrationHarness(
unreadableCheckTimeSlackFixture(),
);
await harness.mount();
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
// Nothing to show, so nothing is shown: the same line a workspace that was
// never checked renders.
expect(harness.lastCheckedLine()).toBeNull();
}, 30000);
it("does not offer a connection check the API is bound to refuse", async () => {
// Given — a workspace connected and no destination channel recorded.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// The check posts to the destination channel, so with none recorded the API
// answers 400 rather than `connected: false`.
expect(await harness.offersConnectionTest()).toBe(false);
// And — the control itself says what unblocks it.
expect(harness.connectionCheckBlockedReason()).toMatch(
/destination channel/i,
);
}, 30000);
});
describe("choosing a destination channel", () => {
it("offers the workspace's channels and remembers the one chosen", async () => {
// Given — a connected tenant whose channels span two cursor pages.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// Then — every channel is offered, so the picker followed `links.next`
// rather than stopping at the first page (design D6). Alphabetically: the
// picker sorts, so the API's page order is not the offered order.
expect(await harness.channelOptions()).toEqual([
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PUBLIC_CHANNEL.name,
SLACK_PRIVATE_CHANNEL.name,
]);
expect(harness.channelListCallCount).toBe(2);
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// Then — only the id is submitted: the API derives the name from it.
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
"PATCH",
"/integrations/",
);
expect(saved?.data.attributes.configuration).toEqual({
channel_id: SLACK_PUBLIC_CHANNEL.id,
});
// And — a later visit shows it, under the name the API derived from the id.
await harness.revisit();
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
}, 60000);
it("narrows the offered channels as the user types", async () => {
// Given — a connected workspace whose channels were read.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// When — the user types part of a name. Then — only the match stays on
// offer, so a long workspace list stays navigable.
const narrowed = await harness.searchChannels("plat");
expect(narrowed.offered).toEqual([SLACK_SECOND_PUBLIC_CHANNEL.name]);
expect(narrowed.emptyNote).toBeNull();
// And — a search matching nothing says so instead of listing channels.
const none = await harness.searchChannels("no-such-channel");
expect(none.offered).toEqual([]);
expect(none.emptyNote).toMatch(/No channel matches/);
}, 60000);
it("offers a private channel the app was invited to, marked as private, and saves it", async () => {
// Given — `@Prowler` was invited to one private channel; `groups:read` is
// membership-gated (D2).
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// Then
expect(await harness.channelOptions()).toContain(
SLACK_PRIVATE_CHANNEL.name,
);
expect(
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
).toBe(true);
expect(
await harness.isChannelShownAsPrivate(SLACK_PUBLIC_CHANNEL.name),
).toBe(false);
// When
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
// Then
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
}, 60000);
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
// Given — a workspace whose only channels are public: `groups:read` is
// membership-gated (design D2).
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channels: [
{ ...SLACK_PUBLIC_CHANNEL },
{ ...SLACK_SECOND_PUBLIC_CHANNEL },
],
}),
);
await harness.mount();
expect(await harness.channelOptions()).not.toContain(
SLACK_PRIVATE_CHANNEL.name,
);
// When — `@Prowler` is invited to a private channel, and the user refreshes
// instead of reconnecting the workspace.
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
await harness.refreshChannels();
// Then
expect(await harness.channelOptions()).toContain(
SLACK_PRIVATE_CHANNEL.name,
);
expect(
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
).toBe(true);
}, 60000);
it("says what to do when the workspace exposes no channel Prowler can post to", async () => {
// Given — a connected workspace exposing no channels at all.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({ channels: [] }),
);
// When
await harness.mount();
// Then — the user is told what to do, not merely that the list is empty.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/No channels available yet/);
expect(message).toMatch(/invite @Prowler/);
expect(await harness.defaultChannel()).toBeNull();
expect(await harness.offersConnectionTest()).toBe(false);
}, 30000);
it("checks the connection itself as soon as the destination is saved", async () => {
// Given — connected with nothing recorded: the check posts to the
// destination, so it is not offered yet.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
expect(await harness.offersConnectionTest()).toBe(false);
expect(harness.connectionCheckBlockedReason()).toMatch(
/destination channel/i,
);
expect(harness.connectionCheckCallCount).toBe(0);
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// Then
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
expect(harness.connectionCheckCallCount).toBe(1);
// And — everything waiting on a destination moves with the save, in the
// same paint: no reload to find the check on offer for later.
expect(await harness.offersConnectionTest()).toBe(true);
expect(harness.connectionCheckBlockedReason()).toBeNull();
}, 60000);
it("reports a saved destination the check cannot reach, without losing the save", async () => {
// Given — a channel the API records, then refuses to reach: the bot is not
// in it, which only the check finds out.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
connection: { connected: false, error: SLACK_NOT_IN_CHANNEL_CODE },
}),
);
await harness.mount();
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// Then — only the check failed, so the destination stays on record.
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.FAILURE);
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(await harness.offersConnectionTest()).toBe(true);
}, 60000);
it("follows the destination recorded elsewhere when the page's data refreshes under it", async () => {
// Given — a finished setup, open on screen.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
// When — the destination changes elsewhere (a second tab, another user) and
// this page's server data refreshes under the open card, as
// `revalidatePath` does after an action.
await harness.channelRecordedElsewhere(SLACK_SECOND_PUBLIC_CHANNEL.name);
await harness.refreshPageData();
// Then — the card reports what is on record, not the copy it took at mount.
expect(await harness.defaultChannel()).toBe(
SLACK_SECOND_PUBLIC_CHANNEL.name,
);
expect(await harness.offersConnectionTest()).toBe(true);
// And — the picker followed too: the superseded destination is not left one
// click from being saved back.
expect(harness.offersChannelSave()).toBe(false);
}, 60000);
it("says which permission is missing when Slack refuses the channel listing, leaving the recorded channel alone", async () => {
// Given — a recorded destination, and an install missing a scope the listing
// needs. The API names it in `code` (contract, Errors), not in `detail`.
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
}),
);
// When
await harness.mount();
// Then — the reason, worded as a fix, with the invite copy still beside the
// picker.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/missing a permission it needs in Slack/);
expect(message).toMatch(/Connect the workspace again and approve/);
// Slack's reason is a protocol token: it travels in `code` and is never
// shown.
expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE);
expect(harness.channelInviteHint()).toMatch(/invites @Prowler/);
// And — a listing Prowler could not read says nothing about the channel
// already recorded.
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(await harness.offersConnectionTest()).toBe(true);
}, 30000);
it("names the wait Slack asked for when it rate limits the channel listing", async () => {
// Given — `conversations.list` is Slack tier 2 and paginated (contract,
// Errors); the `429` carries the wait in `Retry-After`.
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
}),
);
// When
await harness.mount();
// Then — when to come back, not just that it was refused: the wait is
// asserted, not only the wording.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/rate limiting/);
expect(message).toMatch(/about 30 seconds/);
// And — waiting is the fix, so nothing is said about permissions.
expect(message).not.toMatch(/permission/);
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
}, 30000);
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
// Given — a two-page workspace whose second page is rate limited
// (`conversations.list` is tier 2, contract, Errors).
const harness = new SlackIntegrationHarness(partiallyReadSlackFixture());
// When
await harness.mount();
// Then — the picker offers what was read rather than being replaced by the
// refusal: every reload re-runs the same reads into the same limit.
// Alphabetically, as the picker sorts what it offers.
expect(await harness.channelOptions()).toEqual([
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PUBLIC_CHANNEL.name,
]);
expect(harness.saysChannelsUnreadable()).toBe(false);
// And — the wait is still said, as the explanation for the short list.
const notice = harness.partialListNotice();
expect(notice).toMatch(/rate limiting/);
expect(notice).toMatch(/about 30 seconds/);
// And — a partial read says nothing about the destination already recorded.
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(await harness.offersConnectionTest()).toBe(true);
}, 60000);
it("says nothing about a short list when the whole workspace was read", async () => {
// Given — the default workspace: two cursor pages, read to the end.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
// When
await harness.mount();
// Then
expect(harness.partialListNotice()).toBeNull();
}, 30000);
it("falls back to the API's wording when the listing fails upstream", async () => {
// Given — a `502`, which names no `code` because there is nothing to act on
// (contract, Errors).
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
}),
);
// When
await harness.mount();
// Then — the API's own `detail`, and not a wait that was never promised.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/Slack is temporarily unavailable/);
expect(message).not.toMatch(/rate limiting/);
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
}, 30000);
it("says to invite @Prowler when Slack refuses the channel because the app is not in it", async () => {
// Given — a private channel the app was removed from. The API validates the
// channel against Slack on the way in and refuses with `not_in_channel`.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
}),
);
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(
SLACK_PRIVATE_CHANNEL.name,
);
// Then — the one fix the user can carry out themselves, in Slack.
expect(refusal).toMatch(/Prowler is not in that channel/);
expect(refusal).toMatch(/Invite @Prowler to it in Slack/);
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
// And — nothing was recorded, so there is still nothing to check against.
expect(await harness.defaultChannel()).toBeNull();
expect(await harness.offersConnectionTest()).toBe(false);
}, 60000);
it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => {
// Given — a channel archived since the listing was read. The API's `detail`
// is word-for-word the one for `not_in_channel`, so only `code` tells them
// apart.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channelSaveRefusal: SLACK_CHANNEL_NOT_FOUND_REFUSAL,
}),
);
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
// Then — a different problem, so different copy: nothing to invite to a
// channel that no longer exists.
expect(refusal).toMatch(/no longer exists in the workspace/);
expect(refusal).toMatch(/Choose another one/);
expect(refusal).not.toMatch(/Invite @Prowler/);
expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL);
expect(await harness.defaultChannel()).toBeNull();
}, 60000);
});
describe("disconnecting a workspace", () => {
it("removes the integration and returns the card to its unconnected state", async () => {
// Given — a tenant with a workspace connected.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// When — the user opens the confirmation. Only the removal is Prowler's to
// promise: the revocation happens at Slack, which can refuse it or report
// nothing at all.
expect(await harness.openDisconnectConfirmation()).toBe(
"Prowler will remove the integration, stop posting to Prowler HQ, and " +
"attempt to revoke its access at Slack. Connecting again means " +
"approving Prowler in Slack.",
);
// And — the user confirms; Slack confirms the revocation.
expect(await harness.confirmDisconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
expect(harness.disconnectCallCount).toBe(1);
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
it("still removes the integration when the revocation fails, and says access may need removing by hand", async () => {
// Given — Slack will not accept the revocation; the row goes either way.
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
await harness.mount();
// When
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
// And — the disconnect revalidates, so the copy below is read from props
// that no longer carry an integration at all.
await harness.refreshPageData();
// Then — what is true of both sides: nothing is left in Prowler to retry,
// and the app may still be installed at Slack.
const notice = await harness.revocationNotice();
expect(notice).toMatch(/gone from Prowler/);
expect(notice).toMatch(/nothing to retry here/);
expect(notice).toMatch(/may still be installed in Prowler HQ/);
expect(notice).toMatch(
/remove it from that workspace's Slack app settings/,
);
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
it("says only that the workspace is no longer connected when nothing reports the revocation", async () => {
// Given — the plain `204` a deployment that overrides nothing answers: no
// body, so no `meta` to read the outcome from. The case users really meet.
const harness = new SlackIntegrationHarness(
unreportedRevocationSlackFixture(),
);
await harness.mount();
// When
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.UNREPORTED);
// Then — nothing sends the user to Slack to finish a job no answer said
// was unfinished.
expect(harness.showsRevocationNotice()).toBe(false);
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
it("says why the disconnect failed and leaves the workspace connected", async () => {
// Given — a finished setup whose disconnect the API refuses outright, so
// nothing is removed and Slack is never asked to revoke anything.
const harness = new SlackIntegrationHarness(
disconnectRefusalSlackFixture(),
);
await harness.mount();
// When
const refusal = await harness.refusedDisconnect();
// Then — the API's own reason reaches the user, and nothing claims a
// revocation that never ran.
expect(refusal).toMatch(SLACK_DISCONNECT_FORBIDDEN_DETAIL);
expect(harness.showsRevocationNotice()).toBe(false);
// And — the card is untouched: the integration the user still has is the
// one to try again on.
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
}, 30000);
});
describe("a credential Slack no longer accepts", () => {
it("says the connection check found a dead credential, and offers to connect the workspace again", async () => {
// Given — the token was revoked at Slack, so the row still reads connected
// until a check runs (contract, Cross-cutting).
const harness = new SlackIntegrationHarness(revokedTokenSlackFixture());
await harness.mount();
// When
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
// Then — a way forward rather than only an error: a revoked token is fixed
// by approving Prowler again, not by checking a second time.
const notice = await harness.revokedCredentialNotice();
expect(notice).toMatch(/no longer accepts Prowler's access to Prowler HQ/);
expect(notice).toMatch(/Prowler's access to Slack was revoked/);
expect(notice).toMatch(/Connect the workspace again to restore access/);
// Slack's reason is a protocol token: it is what the UI switched on, never
// what it showed.
expect(notice).not.toMatch(new RegExp(SLACK_TOKEN_REVOKED_CODE));
const consentScreen = new URL(await harness.reconnectUrl());
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
"https://slack.com/oauth/v2/authorize",
);
await harness.waitForReconnect();
}, 30000);
it("offers the same recovery when the channel listing is what finds the credential dead", async () => {
// Given — a finished setup whose credential expired. The listing runs on
// arrival, so it meets Slack before any check does, and the contract says
// any call can be the one that surfaces this.
const harness = new SlackIntegrationHarness(
configuredSlackFixture({ channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL }),
);
// When — nothing but opening the page.
await harness.mount();
// Then — the same answer the connection check gives, worded for how this
// credential died rather than left as a channel problem.
const notice = await harness.revokedCredentialNotice();
expect(notice).toMatch(/Prowler's Slack credential has expired/);
expect(notice).toMatch(/Connect the workspace again to restore access/);
await harness.waitForReconnect();
// And — the picker says the same, in the same words: `detail` names the raw
// reason, and it is `code` the UI answered from.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/Prowler's Slack credential has expired/);
expect(message).not.toMatch(new RegExp(SLACK_TOKEN_EXPIRED_CODE));
}, 30000);
it("offers it too when only a later cursor page is what Slack refuses", async () => {
// Given — a two-page workspace whose second page is refused by a credential
// Slack no longer accepts: the read stops short rather than failing.
const harness = new SlackIntegrationHarness(
partiallyReadSlackFixture({
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
}),
);
// When — nothing but opening the page.
await harness.mount();
// Then — what was read stays on offer, as it does for any short list.
// Alphabetically, as the picker sorts what it offers.
expect(await harness.channelOptions()).toEqual([
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PUBLIC_CHANNEL.name,
]);
// And — the dead credential is reported all the same: a picker that still
// works is no reason to leave the user without the one fix there is.
const notice = await harness.revokedCredentialNotice();
expect(notice).toMatch(/Prowler's Slack credential has expired/);
await harness.waitForReconnect();
expect(await harness.connectionBadge()).toBe("Disconnected");
}, 60000);
it("keeps saying so when a later check fails without Slack naming a reason", async () => {
// Given — the listing found the credential dead on arrival, and a later
// check that fails naming no reason at all.
const harness = new SlackIntegrationHarness(
configuredSlackFixture({
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
connection: { connected: false, error: null },
}),
);
await harness.mount();
expect(await harness.revokedCredentialNotice()).toMatch(
/Prowler's Slack credential has expired/,
);
// When
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
// Then — a failure Slack never answered is no evidence the grant works
// again, so the dead credential is still what the page reports.
expect(await harness.revokedCredentialNotice()).toMatch(
/Prowler's Slack credential has expired/,
);
await harness.waitForReconnect();
expect(await harness.connectionBadge()).toBe("Disconnected");
}, 60000);
it("stops saying so once a save Slack validated goes through", async () => {
// Given — a finished setup whose connection check found the grant revoked.
const harness = new SlackIntegrationHarness(
configuredSlackFixture({
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
}),
);
await harness.mount();
expect(await harness.connectionBadge()).toBe("Connected");
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
expect(harness.showsRevokedCredentialNotice()).toBe(true);
expect(await harness.connectionBadge()).toBe("Disconnected");
// When — the access is approved again in Slack and the user saves a
// destination: both the save and the check it runs answer for the grant.
harness.fixture.connection = { connected: true, error: null };
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
// Then — Slack answered, so the notice about a credential it no longer
// accepts goes, and the card is back to what it reported on arrival.
expect(harness.showsRevokedCredentialNotice()).toBe(false);
expect(harness.offersReconnect()).toBe(false);
expect(await harness.connectionBadge()).toBe("Connected");
}, 60000);
});
@@ -32,13 +32,6 @@ export const ProvidersTabContent = async ({
}: {
searchParams: SearchParamsProps;
}) => {
// The React Compiler (`reactCompiler: true`) otherwise instruments this as a
// client component and injects `useMemoCache`, which needs a React dispatcher.
// An async server component renders once per request, so there is nothing to
// memoize — and the injected hook makes it uncallable outside a render, which
// is exactly how the browser-mode tests mount it.
"use no memo";
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({
@@ -0,0 +1 @@
Slack integration: connect a Slack workspace from the Integrations page (Prowler Cloud only)
@@ -752,6 +752,51 @@ export const JiraIcon: React.FC<IconSvgProps> = ({
</svg>
);
export const SlackIcon: React.FC<IconSvgProps> = ({
size = 32,
width,
height,
className = "rounded-md",
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={height ?? size}
viewBox="0 0 48 48"
width={width ?? size}
className={className}
{...props}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z"
fill="#FFFFFF"
/>
{/* Slack mark on its native 122.8 grid, scaled into the 48px tile's 30px
safe area (30 / 122.8 = 0.2443). */}
<g transform="translate(9 9) scale(0.2443)">
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</g>
</svg>
);
export const AWSSecurityHubIcon: React.FC<IconSvgProps> = ({
size = 32,
width,
@@ -6,18 +6,47 @@ import { ReactNode } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
// `null` means never checked, not disconnected: it must not get the fail tokens.
const CONNECTION_BADGE = {
connected: {
label: "Connected",
className:
"bg-bg-pass-secondary text-text-success-primary border-transparent",
},
disconnected: {
label: "Disconnected",
className:
"bg-bg-fail-secondary text-text-error-primary border-transparent",
},
unchecked: {
label: "Not checked yet",
className: "border-border-tag bg-bg-tag text-text-neutral-secondary",
},
} as const;
type ConnectionBadgeState = keyof typeof CONNECTION_BADGE;
const connectionBadgeState = (
connected: boolean | null,
): ConnectionBadgeState =>
connected === null ? "unchecked" : connected ? "connected" : "disconnected";
interface IntegrationCardChip {
label: string;
className?: string;
}
interface IntegrationConnectionStatus {
connected: boolean | null;
label?: string;
}
interface IntegrationCardHeaderProps {
icon: ReactNode;
title: string;
subtitle?: string;
chips?: Array<{
label: string;
className?: string;
}>;
connectionStatus?: {
connected: boolean;
label?: string;
};
chips?: IntegrationCardChip[];
connectionStatus?: IntegrationConnectionStatus;
navigationUrl?: string;
}
@@ -29,6 +58,11 @@ export const IntegrationCardHeader = ({
connectionStatus,
navigationUrl,
}: IntegrationCardHeaderProps) => {
const badgeState = connectionStatus
? connectionBadgeState(connectionStatus.connected)
: null;
const badge = badgeState ? CONNECTION_BADGE[badgeState] : null;
return (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
@@ -55,7 +89,7 @@ export const IntegrationCardHeader = ({
)}
</div>
</div>
{(chips.length > 0 || connectionStatus) && (
{(chips.length > 0 || badge) && (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip, index) => (
<Badge
@@ -69,18 +103,13 @@ export const IntegrationCardHeader = ({
{chip.label}
</Badge>
))}
{connectionStatus && (
{badge && badgeState && (
<Badge
variant="outline"
className={cn(
"text-xs font-normal",
connectionStatus.connected
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
: "bg-bg-fail-secondary text-text-error-primary border-transparent",
)}
data-connection-status={badgeState}
className={cn("text-xs font-normal", badge.className)}
>
{connectionStatus.label ||
(connectionStatus.connected ? "Connected" : "Disconnected")}
{connectionStatus?.label || badge.label}
</Badge>
)}
</div>
@@ -0,0 +1,156 @@
/**
* The cases `slack-page.integration.test.tsx` cannot express: it runs the Server
* Action as a plain function, so there is no clientserver transport to reject,
* and its handler only answers the contract's shapes. React error boundaries
* cannot see a rejection awaited in an effect, so an uncaught one leaves the
* user on the spinner with no error and no way out.
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import { SlackCallback } from "./slack-callback";
const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a";
const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted(
() => ({
exchangeSlackOAuthCode: vi.fn(),
callbackQuery: { value: "" },
routerReplace: vi.fn(),
}),
);
vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode }));
// One router across renders, so the redirect off the spent code is assertable.
const router = { replace: routerReplace };
vi.mock("next/navigation", () => ({
useRouter: () => router,
useSearchParams: () => new URLSearchParams(callbackQuery.value),
}));
beforeEach(() => {
callbackQuery.value = COMPLETED_QUERY;
routerReplace.mockClear();
});
const SPINNER_COPY = /Connecting your Slack workspace/;
/**
* Literals, not imports: a rename on the component's side has to fail here.
* `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes
* that happen before the API consumed the code.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack when the completion answers unexpectedly", () => {
it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => {
// The client→server POST itself fails (dropped connection, action id
// invalidated by a deploy), so the action's own error handling never runs.
exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch"));
render(<SlackCallback />);
// The API consumes the single-use code before answering, so the workspace
// may well be connected: unknown, not failed.
expect(
await screen.findByText(/could not confirm whether the workspace/i),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Back to Slack integration/ }),
).toHaveAttribute("href", "/integrations/slack");
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument();
expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument();
});
it("still reports the workspace as connected when the created integration carries no configuration", async () => {
// The install already succeeded; `configuration` only goes missing on the
// client, where the callback reads the workspace name off it.
exchangeSlackOAuthCode.mockResolvedValue({
integration: {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: null,
connection_last_checked_at: null,
integration_type: "slack",
},
links: { self: "/api/v1/integrations/slack-integration-1" },
// Cast: the shape is the one the contract rules out.
} as unknown as IntegrationProps,
});
render(<SlackCallback />);
expect(
await screen.findByText(/Connected to your Slack workspace/),
).toBeInTheDocument();
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
// Keyed on the escape link, the only element unique to the failure branch,
// so this holds whichever headline that branch would have carried.
expect(
screen.queryByRole("link", { name: /Back to Slack integration/ }),
).not.toBeInTheDocument();
// `replace`, not `push`: a back navigation must not remount onto the code.
expect(routerReplace).toHaveBeenCalledWith("/integrations/slack");
});
});
describe("returning from Slack with an error on the callback URL", () => {
it("says the install was declined when Slack reports the approval was refused", async () => {
// The one code Slack reliably sends to this redirect.
callbackQuery.value = "error=access_denied&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(/was not approved in Slack/),
).toBeInTheDocument();
expect(exchangeSlackOAuthCode).not.toHaveBeenCalled();
// Slack refused before issuing a code, so the flat "not connected" is a
// fact here, unlike in the outcomes that follow an exchange.
expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument();
expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument();
});
it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => {
// Slack publishes no closed set of codes for this redirect, so the guard is
// on the shape of the value rather than on an allowlist.
callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(
"Slack could not complete the install (invalid_scope).",
),
).toBeInTheDocument();
});
it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => {
// The balancing punctuation is the point: it closes Prowler's parenthetical
// and reopens it, so the payload would read as Prowler's own sentence.
const payload =
"). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting (";
callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`;
render(<SlackCallback />);
expect(
await screen.findByText("Slack could not complete the install."),
).toBeInTheDocument();
expect(document.body.textContent).not.toContain("+1-555-0100");
expect(document.body.textContent).not.toContain("flagged this workspace");
});
});
@@ -0,0 +1,159 @@
"use client";
import { AlertCircle, CircleCheck, Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
} from "@/components/shadcn";
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
const SLACK_INTEGRATION_PATH = "/integrations/slack";
const STATUS = {
CONNECTING: "connecting",
CONNECTED: "connected",
FAILED: "failed",
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
const UNCONFIRMED_COMPLETION_MESSAGE =
"Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again.";
const FAILURE_TITLE = "Slack workspace not connected";
/**
* The API consumes the code and upserts the integration before it answers, so an
* unreadable or missing answer can still mean a connected workspace. Kept short:
* `AlertTitle` clamps to one line.
*/
const UNCONFIRMED_TITLE = "Slack install not confirmed";
const describeSlackError = (reason: string): string => {
if (reason === "access_denied") {
return "The install was not approved in Slack, so no workspace was connected.";
}
// `error` comes straight off the URL and is interpolated into Prowler's own
// copy, so gate on the shape of a code: Slack publishes no closed set.
return SLACK_REASON_TOKEN.test(reason)
? `Slack could not complete the install (${reason}).`
: "Slack could not complete the install.";
};
/**
* Slack's `code` is single-use: `hasStarted` holds the exchange to one run per
* mount, and `router.replace` (not `push`) keeps a back navigation from
* remounting onto a spent code.
*/
export const SlackCallback = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<Status>(STATUS.CONNECTING);
const [workspaceName, setWorkspaceName] = useState<string | null>(null);
const [failure, setFailure] = useState<string>("");
const [failureTitle, setFailureTitle] = useState<string>(FAILURE_TITLE);
const hasStarted = useRef(false);
useEffect(() => {
if (hasStarted.current) return;
hasStarted.current = true;
const slackError = searchParams.get("error");
const code = searchParams.get("code");
const state = searchParams.get("state");
// Slack answers a declined install with `error` and no code, so there is
// nothing to exchange.
if (slackError) {
setFailure(describeSlackError(slackError));
setStatus(STATUS.FAILED);
return;
}
if (!code || !state) {
setFailure(
"Slack sent an incomplete response back, so the install could not be completed.",
);
setStatus(STATUS.FAILED);
return;
}
const complete = async () => {
const result = await exchangeSlackOAuthCode({ code, state });
if ("integration" in result) {
setWorkspaceName(
result.integration.attributes?.configuration?.team_name ?? null,
);
setStatus(STATUS.CONNECTED);
router.replace(SLACK_INTEGRATION_PATH);
return;
}
if ("unavailable" in result) {
setFailure("Slack is not available in this environment yet.");
} else if ("rateLimited" in result) {
setFailure(result.message);
} else if ("unconfirmed" in result) {
setFailure(result.message);
setFailureTitle(UNCONFIRMED_TITLE);
} else {
setFailure(result.error);
}
setStatus(STATUS.FAILED);
};
// A rejection here means the call never came back (stale action id after a
// deploy, HTML 502): error boundaries cannot see a rejection awaited inside
// an effect, and the once-guard blocks a retry, so the page would spin.
void complete().catch(() => {
setFailure(UNCONFIRMED_COMPLETION_MESSAGE);
setFailureTitle(UNCONFIRMED_TITLE);
setStatus(STATUS.FAILED);
});
}, [router, searchParams]);
if (status === STATUS.CONNECTING) {
return (
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<Loader2 className="animate-spin" size={16} />
Connecting your Slack workspace...
</div>
);
}
if (status === STATUS.CONNECTED) {
return (
<Alert variant="success">
<CircleCheck />
<AlertTitle>
Connected to {workspaceName ?? "your Slack workspace"}
</AlertTitle>
<AlertDescription>
Taking you back to the Slack integration, where you can choose the
channel Prowler posts to.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col items-start gap-4">
<Alert variant="error">
<AlertCircle />
<AlertTitle>{failureTitle}</AlertTitle>
<AlertDescription>{failure}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href={SLACK_INTEGRATION_PATH}>Back to Slack integration</Link>
</Button>
</div>
);
};
@@ -0,0 +1,195 @@
"use client";
import { ChevronDown, RefreshCw } from "lucide-react";
import { useState } from "react";
import {
Alert,
AlertDescription,
AlertTitle,
Badge,
Button,
Label,
} from "@/components/shadcn";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/shadcn/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/shadcn/popover";
import type { SlackChannelOption } from "@/types/integrations";
const INVITE_HINT =
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
interface SlackChannelSelectorProps {
options: SlackChannelOption[];
value: string | null;
onChange: (channelId: string) => void;
isLoading?: boolean;
/** Why the channels could not be read — Slack's own reason, when it gave one. */
error?: string | null;
/** Why the list is partial. Shown with the picker, not instead of it. */
incompleteNotice?: string | null;
onRefresh?: () => void;
disabled?: boolean;
}
/** Driven entirely by props (design D13) so the alert-rule form can reuse it. */
export const SlackChannelSelector = ({
options,
value,
onChange,
isLoading = false,
error = null,
incompleteNotice = null,
onRefresh,
disabled = false,
}: SlackChannelSelectorProps) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const isEmpty = !isLoading && !error && options.length === 0;
// `htmlFor` may only name an element that exists, and the trigger is only
// rendered in the picker branch.
const hasPicker = !error && !isEmpty;
// A copy: the list belongs to the caller. Sorted here rather than upstream so
// every consumer of the picker offers the same order.
const listed = [...options].sort((left, right) =>
left.name.localeCompare(right.name, undefined, {
sensitivity: "base",
numeric: true,
}),
);
const selected = options.find((option) => option.id === value) ?? null;
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
// Drop the search with the popover, so re-opening it never starts filtered.
if (!open) setQuery("");
};
const handleSelect = (channelId: string) => {
onChange(channelId);
handleOpenChange(false);
};
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={hasPicker ? "slack-channel" : undefined}>
Destination channel
</Label>
{onRefresh && (
<Button
size="sm"
variant="outline"
disabled={isLoading}
onClick={onRefresh}
>
<RefreshCw size={14} />
{isLoading ? "Refreshing..." : "Refresh channels"}
</Button>
)}
</div>
{error ? (
<Alert variant="error">
<AlertTitle>Could not read the workspace&apos;s channels</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : isEmpty ? (
<Alert variant="info">
<AlertTitle>No channels available yet</AlertTitle>
<AlertDescription>
Prowler cannot see a single channel in this workspace. Create a
public channel, or invite @Prowler to a private one in Slack with
<span className="font-medium"> /invite @Prowler</span>, then
refresh.
</AlertDescription>
</Alert>
) : (
<>
{incompleteNotice && (
<Alert variant="warning" data-channels-notice>
<AlertTitle>Not every channel is listed</AlertTitle>
<AlertDescription>{incompleteNotice}</AlertDescription>
</Alert>
)}
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id="slack-channel"
variant="outline"
size="lg"
role="combobox"
aria-expanded={isOpen}
disabled={disabled || isLoading}
className="w-full justify-between"
>
<span className="min-w-0 truncate">
{selected
? `#${selected.name}`
: isLoading
? "Reading channels..."
: "Choose a channel"}
</span>
<ChevronDown size={16} aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) p-0"
>
<Command>
<CommandInput
placeholder="Search channels"
value={query}
onValueChange={setQuery}
aria-label="Search channels"
/>
<CommandList>
{/* A workspace with no channels at all is a different
situation, answered by the alert above. */}
<CommandEmpty>No channel matches that search.</CommandEmpty>
<CommandGroup>
{listed.map((option) => (
<CommandItem
key={option.id}
// The search matches on this value, so it carries the
// name the user types; the id travels to `onChange`
// through the closure. A name can be empty on the wire.
value={option.name || option.id}
onSelect={() => handleSelect(option.id)}
// Name hook: the rendered label mixes it with a
// "Private" badge.
data-channel={option.name}
>
<span className="min-w-0 truncate">#{option.name}</span>
{option.is_private && (
<Badge variant="tag" size="sm">
Private
</Badge>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</>
)}
<p className="text-text-neutral-secondary text-xs">{INVITE_HINT}</p>
</div>
);
};
@@ -0,0 +1,55 @@
import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
// Placeholder slug: the docs slice writes the page and confirms it.
const SLACK_DOCS_URL =
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/";
export const SlackIntegrationCard = () => {
return (
<Card variant="base" padding="lg">
<CardHeader>
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<SlackIcon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
Slack
</h4>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<p className="text-xs text-nowrap text-gray-500 dark:text-gray-300">
Send Prowler messages to your Slack workspace.
</p>
<CustomLink
href={SLACK_DOCS_URL}
aria-label="Learn more about Slack integration"
size="xs"
>
Learn more
</CustomLink>
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<Button asChild size="sm">
<Link href="/integrations/slack">
<SettingsIcon size={14} />
Manage
</Link>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect a Slack workspace and pick the channel Prowler posts to, so
your team gets security updates where it already works.
</p>
</CardContent>
</Card>
);
};
@@ -0,0 +1,65 @@
/**
* The case `slack-page.integration.test.tsx` cannot express: it asserts against
* a hydrated, settled page, so it never sees the first frame the user is
* served. The effect that reads the channels only runs in the browser, so the
* channel state at render time is what the served HTML says until hydration.
*/
import { renderToString } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
import { SlackIntegrationManager } from "./slack-integration-manager";
vi.mock("@/actions/integrations/slack", () => ({
getSlackChannels: vi.fn(),
setSlackDefaultChannel: vi.fn(),
}));
vi.mock("@/actions/integrations/integrations", () => ({
testIntegrationConnection: vi.fn(),
}));
/**
* A connected workspace with no channel recorded, as the contract has it before
* a save: with one, the picker would show that channel instead of the
* placeholder this test reads.
*/
const CONNECTED_WORKSPACE: IntegrationProps = {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-08-10T09:05:00Z",
integration_type: INTEGRATION_TYPE.SLACK,
configuration: {
team_id: "T024BE7LD",
team_name: "Prowler HQ",
bot_user_id: "U0KRQLJ9H",
},
},
links: { self: "/api/v1/integrations/slack-integration-1" },
};
describe("the first paint of a connected workspace", () => {
it("reads as still reading the channels rather than as a workspace with none", () => {
// When
const serverHtml = renderToString(
<SlackIntegrationManager
integration={CONNECTED_WORKSPACE}
authorizeUrl={null}
unavailable={false}
rateLimitMessage={null}
loadError={null}
/>,
);
// Then
expect(serverHtml).toContain("Reading channels...");
expect(serverHtml).not.toContain("No channels available yet");
});
});
@@ -0,0 +1,661 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import { TestTube, Unplug } from "lucide-react";
import { useEffect, useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import {
disconnectSlackIntegration,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackDefaultChannel,
} from "@/actions/integrations/slack";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { IntegrationCardHeader } from "@/components/integrations/shared";
import { SlackChannelSelector } from "@/components/integrations/slack/slack-channel-selector";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
useToast,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
isSlackTokenErrorCode,
SLACK_REASON_TOKEN,
slackErrorMessage,
} from "@/lib/integrations/slack-errors";
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
import type {
IntegrationProps,
SlackChannelOption,
} from "@/types/integrations";
const CHANNELS_STATUS = {
LOADING: "loading",
ERROR: "error",
LOADED: "loaded",
} as const;
interface ChannelsLoading {
status: typeof CHANNELS_STATUS.LOADING;
}
interface ChannelsFailed {
status: typeof CHANNELS_STATUS.ERROR;
message: string;
}
interface ChannelsLoaded {
status: typeof CHANNELS_STATUS.LOADED;
channels: SlackChannelOption[];
// Rides with the list it qualifies, so it can never outlive it.
notice: string | null;
}
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
const CHECK_BLOCKED_REASON_ID = "slack-connection-check-blocked";
/**
* A disconnect that removed the row without Slack confirming the revocation.
* The workspace name travels with it: the notice exists to name the workspace
* to clean up, and the record is gone by the time revalidation lands.
*/
interface UnconfirmedRevocation {
workspace: string | null;
}
// The name may be missing: the id decides what the UI can do with it.
interface SlackChannelRef {
id: string;
name: string | null;
}
const channelRefEquals = (
a: SlackChannelRef | null,
b: SlackChannelRef | null,
) => a?.id === b?.id && a?.name === b?.name;
/**
* Slack's own reason, when the string is one: the connection check reports a
* reason and its own prose in the same field, and only a reason is an answer
* from Slack about the credential.
*/
const asReasonCode = (reason: string | null): string | null =>
reason && SLACK_REASON_TOKEN.test(reason) ? reason : null;
interface SlackIntegrationManagerProps {
/** At most one exists per tenant (one workspace). */
integration: IntegrationProps | null;
authorizeUrl: string | null;
/** This deployment has no Prowler Slack app, so no install can be started. */
unavailable: boolean;
rateLimitMessage: string | null;
loadError: string | null;
}
export const SlackIntegrationManager = ({
integration,
authorizeUrl,
unavailable,
rateLimitMessage,
loadError,
}: SlackIntegrationManagerProps) => {
const [isTesting, setIsTesting] = useState(false);
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
// The row is gone the moment the API says so; the server component's
// revalidation only catches up on the next navigation.
const [disconnected, setDisconnected] = useState(false);
const [unconfirmedRevocation, setUnconfirmedRevocation] =
useState<UnconfirmedRevocation | null>(null);
/**
* The `code` of the last refusal any Slack-backed call ran into, or `null`
* when the last answer was not a refusal. A dead grant can surface from any
* of them (contract, Cross-cutting), so every call reports here instead of
* deciding on its own.
*/
const [lastRefusalCode, setLastRefusalCode] = useState<string | null>(null);
// A connected workspace arrives with no consent URL, since no install is left
// to start (design D10), so one is minted only if a reconnect turns out to be
// the way out.
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
const { toast } = useToast();
const integrationId = integration?.id ?? null;
const recordedChannelId =
integration?.attributes.configuration.channel_id ?? null;
const recordedChannelName =
integration?.attributes.configuration.channel_name ?? null;
const recordedChannel: SlackChannelRef | null = recordedChannelId
? { id: recordedChannelId, name: recordedChannelName }
: null;
// Seeded `loading`, not by the effect: the effect never runs on the server,
// so anything else would server-render a "no channels" picker until
// hydration.
const [channelsState, setChannelsState] = useState<ChannelsState>(
integrationId
? { status: CHANNELS_STATUS.LOADING }
: { status: CHANNELS_STATUS.LOADED, channels: [], notice: null },
);
// Bumped by refresh: a channel invited after load only shows on a re-read.
const [channelReloads, setChannelReloads] = useState(0);
// Local state needed: the pick is buffered until the user saves it.
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
recordedChannelId,
);
// Mirrored in state, not read from the prop, so channel-gated affordances
// move on save instead of waiting for the revalidation.
const [defaultChannel, setDefaultChannel] = useState(recordedChannel);
// The prop the mirror was last taken from: the card never unmounts, so a
// mirror seeded only at mount would go stale when the record changes.
const [syncedChannel, setSyncedChannel] = useState(recordedChannel);
const [isSavingChannel, setIsSavingChannel] = useState(false);
if (!channelRefEquals(recordedChannel, syncedChannel)) {
const previousSyncedId = syncedChannel?.id ?? null;
setSyncedChannel(recordedChannel);
setDefaultChannel(recordedChannel);
// Follow the record only while the buffered pick still matches it: an
// unsaved pick is the user's, not ours to overwrite mid-edit.
if (selectedChannelId === previousSyncedId) {
setSelectedChannelId(recordedChannel?.id ?? null);
}
}
// Only an answer from Slack moves the bus: a call that never got one proves
// nothing and leaves the last answer standing.
const provedCredentialAlive = () => setLastRefusalCode(null);
const recordRefusal = (code: string | null | undefined) => {
if (code) setLastRefusalCode(code);
};
/**
* Whether the last refusal proves the grant itself is dead, rather than a
* channel unreachable or Slack busy. Derived, not stored, so it self-clears:
* a later call Slack answered at all (even to refuse a channel) is proof the
* credential works again, and the notice goes with it.
*/
const credentialFailure: SlackTokenErrorCode | null = isSlackTokenErrorCode(
lastRefusalCode,
)
? lastRefusalCode
: null;
const needsInstallUrl = disconnected || credentialFailure !== null;
useEffect(() => {
if (!needsInstallUrl) return;
let cancelled = false;
getSlackAuthorizeUrl()
.then((result) => {
if (cancelled || !("authorizeUrl" in result)) return;
setMintedInstallUrl(result.authorizeUrl);
})
.catch(() => {
// Nothing to say: the page loses a shortcut, not a way to reconnect.
});
return () => {
cancelled = true;
};
}, [needsInstallUrl]);
useEffect(() => {
if (!integrationId) return;
let cancelled = false;
setChannelsState({ status: CHANNELS_STATUS.LOADING });
getSlackChannels(integrationId)
.then((result) => {
if (cancelled) return;
setChannelsState(
"error" in result
? { status: CHANNELS_STATUS.ERROR, message: result.error }
: {
status: CHANNELS_STATUS.LOADED,
channels: result.channels,
notice: result.incomplete ?? null,
},
);
// The listing runs on arrival, so it is where a dead credential shows
// up first. A read cut short still names its refusal's code, so a grant
// that died on a later cursor page is heard too; a truncation naming
// none was Slack busy, not refusing.
if ("error" in result || result.code) recordRefusal(result.code);
else provedCredentialAlive();
})
.catch(() => {
if (cancelled) return;
setChannelsState({
status: CHANNELS_STATUS.ERROR,
message: "Could not reach Slack to read the channel list.",
});
});
return () => {
cancelled = true;
};
}, [integrationId, channelReloads]);
const channels =
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.channels
: [];
const handleSaveChannel = async () => {
if (!integrationId || !selectedChannelId) return;
let saved = false;
setIsSavingChannel(true);
try {
// Only the id travels — the API validates it and derives the name
// (design D6).
const result = await setSlackDefaultChannel(
integrationId,
selectedChannelId,
);
if ("error" in result) {
// The API validates the channel against Slack, so the save can
// discover the credential is gone.
recordRefusal(result.code);
toast({
variant: "destructive",
title: "Could not save the destination channel",
description: result.error,
});
return;
}
// Prefer the API's derived name: a channel renamed in Slack since the
// list was read would otherwise show its old name.
const savedName =
result.integration.attributes.configuration.channel_name ??
channels.find((channel) => channel.id === selectedChannelId)?.name ??
null;
provedCredentialAlive();
setDefaultChannel({ id: selectedChannelId, name: savedName });
saved = true;
toast({
title: "Destination channel saved",
description: savedName
? `Prowler will post to #${savedName}.`
: "Prowler will post to the channel you chose.",
});
} catch (_error) {
toast({
variant: "destructive",
title: "Could not save the destination channel",
description: "Something went wrong. Please try again.",
});
} finally {
setIsSavingChannel(false);
}
// Recording a destination is what makes a check possible (design D7), and
// the save alone only proves the API took the id.
if (saved) await handleTestConnection(integrationId);
};
const handleTestConnection = async (id: string) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
provedCredentialAlive();
toast({
title: "Connection test successful!",
description:
result.message || "Prowler can reach your Slack workspace.",
});
} else {
// A dead credential named here is not a failure checking again can
// fix, so the reason is recorded and not only reported.
const reason = result.error?.trim() || null;
recordRefusal(asReasonCode(reason));
toast({
variant: "destructive",
title: "Connection test failed",
description: reason
? slackErrorMessage({ code: reason, detail: reason })
: "Failed to reach your Slack workspace.",
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(false);
}
};
const handleDisconnect = async (id: string) => {
const recordedWorkspace =
integration?.attributes.configuration.team_name ?? null;
const workspace = recordedWorkspace ?? "your Slack workspace";
setIsDisconnecting(true);
try {
const result = await disconnectSlackIntegration(id);
if ("error" in result) {
toast({
variant: "destructive",
title: "Disconnect failed",
description: result.error,
});
return;
}
const { revoked } = result.revocation;
// The row is gone whatever Slack answered, so the page goes back to its
// unconnected state either way, and a dead credential is moot once the
// row it belonged to is gone.
setDisconnected(true);
setLastRefusalCode(null);
// Only an explicit `false` sends the user to finish the job in Slack: an
// unreported outcome is neither a failed revocation nor a confirmed one,
// so it claims neither.
setUnconfirmedRevocation(
revoked === false ? { workspace: recordedWorkspace } : null,
);
if (revoked !== false) {
toast({
title: "Slack workspace disconnected",
description:
revoked === true
? `Prowler's access to ${workspace} has been revoked.`
: `${workspace} is no longer connected to Prowler.`,
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to disconnect Slack. Please try again.",
});
} finally {
setIsDisconnecting(false);
setIsDisconnectOpen(false);
}
};
const workspaceName = integration?.attributes.configuration.team_name;
const installUrl = mintedInstallUrl ?? authorizeUrl;
const checkedAt = integration?.attributes.connection_last_checked_at;
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
// `format` throws a RangeError on an unreadable value, which would replace
// the page with the route's error boundary: show nothing instead, as for a
// connection that was never checked.
const lastCheckedOn =
checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null;
return (
<div className="flex flex-col gap-6">
{rateLimitMessage && (
<Alert variant="warning">
<AlertTitle>Slack is busy right now</AlertTitle>
<AlertDescription>{rateLimitMessage}</AlertDescription>
</Alert>
)}
<Modal
open={isDisconnectOpen}
onOpenChange={setIsDisconnectOpen}
title="Disconnect Slack workspace"
description={`Prowler will remove the integration, stop posting to ${workspaceName ?? "this workspace"}, and attempt to revoke its access at Slack. Connecting again means approving Prowler in Slack.`}
>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isDisconnecting}
onClick={() => integration && handleDisconnect(integration.id)}
>
{!isDisconnecting && <Unplug size={20} />}
{isDisconnecting ? "Disconnecting..." : "Disconnect workspace"}
</Button>
</div>
</Modal>
{loadError && (
<Alert variant="error">
<AlertTitle>Could not load your Slack integration</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
)}
{unconfirmedRevocation && (
<Alert variant="warning">
<AlertTitle>
Slack disconnected remove Prowler&apos;s access in Slack
</AlertTitle>
<AlertDescription>
The integration and the token Prowler had stored are gone from
Prowler, so there is nothing to retry here. Slack did not confirm
the revocation, so the Prowler app may still be installed in{" "}
{unconfirmedRevocation.workspace ?? "the workspace"} remove it
from that workspace&apos;s Slack app settings.
</AlertDescription>
</Alert>
)}
{credentialFailure && (
<Alert variant="error">
<AlertTitle>
Slack no longer accepts Prowler&apos;s access to{" "}
{workspaceName ?? "this workspace"}
</AlertTitle>
{/* Each mapped sentence already ends in the thing that fixes it. */}
<AlertDescription>
{slackErrorMessage({ code: credentialFailure })} Until then, nothing
Prowler sends will reach the workspace.
</AlertDescription>
{installUrl && (
<div className="col-start-2 mt-3">
<Button asChild size="sm">
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Reconnect to Slack
</a>
</Button>
</div>
)}
</Alert>
)}
{/* Replaces the cards, not the whole page: an early return here would
swallow the rate-limit and load-error notices above. */}
{unavailable ? (
<Alert variant="info">
<AlertTitle>
Slack is not available in this environment yet
</AlertTitle>
<AlertDescription>
The Prowler Slack app is not configured here, so no workspace can be
connected. Nothing to do on your side this page starts working as
soon as it is.
</AlertDescription>
</Alert>
) : integration && !disconnected ? (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
subtitle="Prowler posts to this workspace only."
connectionStatus={{
// A dead token outranks the state the page was loaded with.
connected:
credentialFailure === null
? integration.attributes.connected
: false,
}}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{lastCheckedOn && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{lastCheckedOn}
</p>
)}
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{/* The check posts to the destination channel: the API answers
400 when none is recorded yet. */}
<Button
size="sm"
variant="outline"
disabled={isTesting || !defaultChannel}
// The reason travels with the control: a disabled button
// whose explanation sits across the row reads as broken.
aria-describedby={
defaultChannel ? undefined : CHECK_BLOCKED_REASON_ID
}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(true)}
>
<Unplug size={14} />
Disconnect
</Button>
</div>
{!defaultChannel && (
<p
id={CHECK_BLOCKED_REASON_ID}
className="text-xs text-gray-500 dark:text-gray-300"
>
Choose a destination channel below to enable this check.
</p>
)}
</div>
</div>
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
<SlackChannelSelector
options={channels}
value={selectedChannelId}
onChange={setSelectedChannelId}
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
error={
channelsState.status === CHANNELS_STATUS.ERROR
? channelsState.message
: null
}
incompleteNotice={
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.notice
: null
}
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
disabled={isSavingChannel}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-text-neutral-secondary text-xs">
{/* The id decides, not the name: a missing name would deny
a destination the check runs against. */}
{defaultChannel
? defaultChannel.name
? `Prowler posts to #${defaultChannel.name}.`
: "Prowler posts to the channel you saved."
: "No destination channel recorded yet."}
</p>
<Button
size="sm"
disabled={
!selectedChannelId ||
selectedChannelId === (defaultChannel?.id ?? null) ||
isSavingChannel ||
isTesting
}
onClick={handleSaveChannel}
>
{isSavingChannel ? "Saving..." : "Save channel"}
</Button>
</div>
</div>
</CardContent>
</Card>
) : (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title="No workspace connected"
subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy."
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-300">
Prowler asks for permission to post messages and to read the
workspace&apos;s channel list.
</p>
{installUrl ? (
<Button asChild>
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Add to Slack
</a>
</Button>
) : (
<Button disabled>
<SlackIcon size={16} />
Add to Slack
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
};
+189
View File
@@ -0,0 +1,189 @@
/**
* Unit-tested because the mapping carries copy for codes no page-level flow can
* be driven into `no_permission`, `invalid_auth`, `account_inactive`.
*/
import { describe, expect, it } from "vitest";
import {
isSlackTokenErrorCode,
SLACK_ERROR_CODE,
SLACK_ERROR_MESSAGES,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_RATE_LIMITED_MESSAGE,
SLACK_REASON_TOKEN,
SLACK_TOKEN_ERROR_CODES,
readSlackFailure,
slackErrorMessage,
slackRateLimitMessage,
} from "./slack-errors";
describe("slackErrorMessage", () => {
it("prefers the code's own copy over the API's wording", () => {
// `detail` states the condition; the code's copy states the fix.
const failure = {
code: SLACK_ERROR_CODE.WORKSPACE_CONFLICT,
detail:
"This tenant is already connected to a different Slack workspace.",
};
expect(slackErrorMessage(failure)).toBe(
SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.WORKSPACE_CONFLICT],
);
expect(slackErrorMessage(failure)).toMatch(/Disconnect it/);
});
it("tells the user how to grant a scope Prowler is missing", () => {
// A missing scope is fixable by the reader, so the copy names the fix.
const message = slackErrorMessage({
code: SLACK_ERROR_CODE.MISSING_SCOPE,
detail: "missing_scope",
});
expect(message).toMatch(/Connect the workspace again/);
expect(message).not.toMatch(/missing_scope/);
});
it("says what to do about each channel refusal", () => {
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.CHANNEL_NOT_FOUND }),
).toMatch(/Choose another one/);
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }),
).toMatch(/Invite @Prowler/);
expect(slackErrorMessage({ code: SLACK_ERROR_CODE.NO_PERMISSION })).toMatch(
/Choose another channel/,
);
});
it("points every dead-credential code at reconnecting, not at retrying", () => {
for (const code of SLACK_TOKEN_ERROR_CODES) {
// Revoked, invalid, inactive or expired: no retry helps for any of them.
expect(slackErrorMessage({ code })).toMatch(
/Connect the workspace again to restore access/,
);
expect(isSlackTokenErrorCode(code)).toBe(true);
}
});
it("does not treat an actionable refusal as a dead credential", () => {
expect(isSlackTokenErrorCode(SLACK_ERROR_CODE.MISSING_SCOPE)).toBe(false);
expect(isSlackTokenErrorCode(null)).toBe(false);
expect(isSlackTokenErrorCode(undefined)).toBe(false);
});
it("falls back to the API's detail for a code it does not know", () => {
expect(
slackErrorMessage({
code: "some_future_slack_reason",
detail: "Slack said no.",
}),
).toBe("Slack said no.");
});
it("falls back to the generic line when there is neither", () => {
expect(slackErrorMessage({ code: null, detail: null })).toBe(
SLACK_GENERIC_ERROR_MESSAGE,
);
expect(slackErrorMessage(null)).toBe(SLACK_GENERIC_ERROR_MESSAGE);
expect(
slackErrorMessage({ detail: " " }, "Could not read channels."),
).toBe("Could not read channels.");
});
it("falls back only for a code the mapping does not cover", () => {
const FALLBACK = "Slack refused it (is_archived).";
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }, FALLBACK),
).toBe(SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL]);
// No `detail`: one holding the same token would make the raw token the
// whole message again.
expect(slackErrorMessage({ code: "is_archived" }, FALLBACK)).toBe(FALLBACK);
});
});
describe("SLACK_REASON_TOKEN", () => {
it("recognises a reason code and refuses anything that reads as a sentence", () => {
// Slack publishes no closed set of reasons, so the guard is on shape rather
// than an allowlist.
for (const reason of [
"is_archived",
"restricted_action",
"team_access_not_granted",
"ekm_access_denied",
"messages_tab_disabled",
]) {
expect(SLACK_REASON_TOKEN.test(reason)).toBe(true);
}
for (const prose of [
"Slack rejected the message: the channel is archived.",
"). Contact support at +1-555-0100 (",
"",
"a".repeat(49),
]) {
expect(SLACK_REASON_TOKEN.test(prose)).toBe(false);
}
});
});
describe("slackRateLimitMessage", () => {
it("names the wait Slack asked for", () => {
expect(slackRateLimitMessage(30)).toMatch(/about 30 seconds/);
expect(slackRateLimitMessage(1)).toMatch(/about 1 second\b/);
expect(slackRateLimitMessage(90)).toMatch(/about 2 minutes/);
});
it("still says to come back when Slack named no wait", () => {
expect(slackRateLimitMessage(null)).toBe(SLACK_RATE_LIMITED_MESSAGE);
expect(slackRateLimitMessage(0)).toBe(SLACK_RATE_LIMITED_MESSAGE);
});
});
describe("readSlackFailure", () => {
it("reads the code, the detail and the wait off a JSON:API refusal", async () => {
const response = new Response(
JSON.stringify({
errors: [
{
status: "429",
detail: "Slack is rate limiting requests from Prowler.",
source: { pointer: "/data" },
},
],
}),
{ status: 429, headers: { "Retry-After": "30" } },
);
const failure = await readSlackFailure(response);
expect(failure).toEqual({
status: 429,
code: null,
detail: "Slack is rate limiting requests from Prowler.",
retryAfterSeconds: 30,
});
});
it("survives a body that is not JSON:API at all", async () => {
const failure = await readSlackFailure(
new Response("<html>Bad gateway</html>", { status: 502 }),
);
expect(failure).toEqual({
status: 502,
code: null,
detail: null,
retryAfterSeconds: null,
});
});
it("ignores a Retry-After it cannot use", async () => {
const failure = await readSlackFailure(
new Response("{}", { status: 429, headers: { "Retry-After": "soon" } }),
);
expect(failure.retryAfterSeconds).toBeNull();
});
});
+148
View File
@@ -0,0 +1,148 @@
export const SLACK_ERROR_CODE = {
MISSING_SCOPE: "missing_scope",
CHANNEL_NOT_FOUND: "channel_not_found",
NOT_IN_CHANNEL: "not_in_channel",
NO_PERMISSION: "no_permission",
TOKEN_REVOKED: "token_revoked",
INVALID_AUTH: "invalid_auth",
ACCOUNT_INACTIVE: "account_inactive",
TOKEN_EXPIRED: "token_expired",
/** One workspace per tenant. */
WORKSPACE_CONFLICT: "slack_workspace_conflict",
} as const;
export type SlackErrorCode =
(typeof SLACK_ERROR_CODE)[keyof typeof SLACK_ERROR_CODE];
/**
* The grant itself is dead: reconnecting is the only way out, not retrying. The
* API answers these with `400`, not `401`, so they are not mistaken for an
* expired Prowler session.
*/
export const SLACK_TOKEN_ERROR_CODES = [
SLACK_ERROR_CODE.TOKEN_REVOKED,
SLACK_ERROR_CODE.INVALID_AUTH,
SLACK_ERROR_CODE.ACCOUNT_INACTIVE,
SLACK_ERROR_CODE.TOKEN_EXPIRED,
] as const;
export type SlackTokenErrorCode = (typeof SLACK_TOKEN_ERROR_CODES)[number];
export const isSlackTokenErrorCode = (
code: string | null | undefined,
): code is SlackTokenErrorCode =>
SLACK_TOKEN_ERROR_CODES.includes(code as SlackTokenErrorCode);
export const SLACK_GENERIC_ERROR_MESSAGE =
"Slack could not complete that request. Try again in a moment.";
export const SLACK_RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in a few moments.";
/**
* For a channel list that stopped short of the workspace: the page budget ran
* out, or `links.next` left the API's origin.
*/
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler has to be invited to.";
/**
* For a `2xx` the UI could not read. Not phrased as a failure: the install
* happened, only the workspace cannot be named.
*/
export const SLACK_UNREADABLE_RESULT_MESSAGE =
"Prowler could not read the result of the install. Open the Slack integration page to see the workspace — if none is listed there, start the install again.";
/**
* The shape of a Slack reason code, as opposed to a sentence: the set is
* open-ended, so a reason is gated on its shape before being interpolated.
*/
export const SLACK_REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
const RECONNECT = "Connect the workspace again to restore access.";
export const SLACK_ERROR_MESSAGES = {
[SLACK_ERROR_CODE.MISSING_SCOPE]:
"Prowler is missing a permission it needs in Slack. Connect the workspace again and approve the access Prowler asks for.",
[SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]:
"That channel no longer exists in the workspace. Choose another one.",
[SLACK_ERROR_CODE.NOT_IN_CHANNEL]:
"Prowler is not in that channel. Invite @Prowler to it in Slack, or choose a channel it can already post to.",
[SLACK_ERROR_CODE.NO_PERMISSION]:
"Slack did not allow Prowler to post there. Choose another channel, or ask a workspace admin to allow it.",
[SLACK_ERROR_CODE.TOKEN_REVOKED]: `Prowler's access to Slack was revoked. ${RECONNECT}`,
[SLACK_ERROR_CODE.INVALID_AUTH]: `Slack no longer accepts Prowler's credential. ${RECONNECT}`,
[SLACK_ERROR_CODE.ACCOUNT_INACTIVE]: `The Slack account Prowler was installed with is no longer active. ${RECONNECT}`,
[SLACK_ERROR_CODE.TOKEN_EXPIRED]: `Prowler's Slack credential has expired. ${RECONNECT}`,
[SLACK_ERROR_CODE.WORKSPACE_CONFLICT]:
"Prowler is already connected to a different Slack workspace. Disconnect it before connecting another one.",
} as const satisfies Record<SlackErrorCode, string>;
/** The parts of a JSON:API error this mapping reads. */
export interface SlackErrorSource {
code?: string | null;
detail?: string | null;
}
export interface SlackApiFailure extends SlackErrorSource {
status: number;
retryAfterSeconds: number | null;
}
const isKnownCode = (code: string | null | undefined): code is SlackErrorCode =>
typeof code === "string" &&
Object.prototype.hasOwnProperty.call(SLACK_ERROR_MESSAGES, code);
/**
* Copy for a refusal: Prowler's wording for a known `code`, else the API's
* `detail`, else `fallback`.
*/
export const slackErrorMessage = (
error: SlackErrorSource | null | undefined,
fallback: string = SLACK_GENERIC_ERROR_MESSAGE,
): string => {
if (isKnownCode(error?.code)) return SLACK_ERROR_MESSAGES[error.code];
return error?.detail?.trim() || fallback;
};
const describeWait = (seconds: number): string => {
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
const minutes = Math.ceil(seconds / 60);
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
};
export const slackRateLimitMessage = (
retryAfterSeconds: number | null,
): string => {
if (retryAfterSeconds === null || retryAfterSeconds <= 0) {
return SLACK_RATE_LIMITED_MESSAGE;
}
return `Slack is rate limiting Prowler right now. Try again in about ${describeWait(
Math.ceil(retryAfterSeconds),
)}.`;
};
const retryAfterFrom = (response: Response): number | null => {
const header = response.headers.get("retry-after");
if (!header) return null;
const seconds = Number(header.trim());
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
};
/**
* Read a non-OK Slack response into the failure it describes. Never throws: a
* body that is not JSON:API still yields a failure carrying the status.
*/
export const readSlackFailure = async (
response: Response,
): Promise<SlackApiFailure> => {
const body = await response.json().catch(() => null);
const error = Array.isArray(body?.errors) ? body.errors[0] : null;
return {
status: response.status,
code: typeof error?.code === "string" ? error.code : null,
detail: typeof error?.detail === "string" ? error.detail : null,
retryAfterSeconds: retryAfterFrom(response),
};
};
+30 -2
View File
@@ -2,7 +2,15 @@ import { z } from "zod";
import type { TaskState } from "@/types/tasks";
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
export const INTEGRATION_TYPE = {
AMAZON_S3: "amazon_s3",
AWS_SECURITY_HUB: "aws_security_hub",
JIRA: "jira",
SLACK: "slack",
} as const;
export type IntegrationType =
(typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE];
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",
@@ -68,7 +76,10 @@ export interface IntegrationProps {
inserted_at: string;
updated_at: string;
enabled: boolean;
connected: boolean;
// `null` until a connection check has run: never verified, neither working
// nor broken. A Slack install starts here, and returns here on a channel
// change.
connected: boolean | null;
connection_last_checked_at: string | null;
integration_type: IntegrationType;
configuration: {
@@ -87,6 +98,13 @@ export interface IntegrationProps {
domain?: string;
projects?: { [key: string]: string };
issue_types?: { [key: string]: string[] };
// Slack specific configuration, server-owned. The channel keys are absent
// until one is chosen, not present and null: read them with `?? null`.
team_id?: string;
team_name?: string;
bot_user_id?: string;
channel_id?: string;
channel_name?: string;
[key: string]: unknown;
};
url?: string;
@@ -95,6 +113,16 @@ export interface IntegrationProps {
links: { self: string };
}
/**
* A channel Prowler can post to: every active public channel, plus the private
* ones `@Prowler` was invited to. `is_private` keeps the API's own naming.
*/
export interface SlackChannelOption {
id: string;
name: string;
is_private: boolean;
}
// Jira dispatch types
export interface JiraDispatchRequest {
data: {
+30 -8
View File
@@ -1,9 +1,34 @@
import react from "@vitejs/plugin-react";
import react, { type BabelOptions } from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import fs from "fs";
import path from "path";
import type { TestProjectConfiguration } from "vitest/config";
import { defineConfig } from "vitest/config";
/**
* Next runs the React Compiler on the client compilation only its
* `getReactCompilerPlugins` returns nothing when `isServer` so a Server
* Component ships uncompiled. Mirror that: compiled, it calls `useMemoCache`
* on the active dispatcher, which a harness invoking the component as a
* function has none of, and `react/compiler-runtime` reads the client
* internals the `react-server` build does not export anyway.
*/
const isServerModule = (id: string): boolean => {
const file = id.split("?")[0];
if (!file.includes("/app/")) return false;
try {
return !/^\s*["']use client["']/.test(fs.readFileSync(file, "utf8"));
} catch {
return false;
}
};
const reactCompilerBabel = (id: string): BabelOptions => ({
plugins: isServerModule(id)
? []
: [["babel-plugin-react-compiler", { target: "19" }]],
});
export default defineConfig(() => {
const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1";
@@ -57,13 +82,7 @@ export default defineConfig(() => {
},
{
extends: true,
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
},
}),
],
plugins: [react({ babel: reactCompilerBabel })],
test: {
name: "integration",
setupFiles: ["./vitest.integration.setup.ts"],
@@ -109,6 +128,9 @@ export default defineConfig(() => {
// React runtime (pre-bundle so a cold run doesn't re-optimize and
// reload mid-test — see the on-demand-reload note above).
"react-dom/client",
// What the compiler's output imports. `@vitejs/plugin-react` adds it
// itself only when `babel` is a plain object, and ours is a function.
"react/compiler-runtime",
// Next runtime
"next/headers",
Generated
+17
View File
@@ -3845,6 +3845,7 @@ dependencies = [
{ name = "stackit-iaas" },
{ name = "stackit-objectstorage" },
{ name = "stackit-resourcemanager" },
{ name = "stackit-ske" },
{ name = "tabulate" },
{ name = "tzlocal" },
{ name = "uuid6" },
@@ -3966,6 +3967,7 @@ requires-dist = [
{ name = "stackit-iaas", specifier = "==1.4.0" },
{ name = "stackit-objectstorage", specifier = "==1.4.0" },
{ name = "stackit-resourcemanager", specifier = "==0.8.0" },
{ name = "stackit-ske", specifier = "==1.12.0" },
{ name = "tabulate", specifier = "==0.9.0" },
{ name = "tzlocal", specifier = "==5.3.1" },
{ name = "uuid6", specifier = "==2024.7.10" },
@@ -5121,6 +5123,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
]
[[package]]
name = "stackit-ske"
version = "1.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/9e/df3ad585cb96d028354f4253568e9879d81bb9395d5ebfa268fa9350e2df/stackit_ske-1.12.0.tar.gz", hash = "sha256:62814279f3b7fb2387648f92d14453a8905ad60115c07579f2741ddb7d1fcc94", size = 37239, upload-time = "2026-06-30T11:18:49.39Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/37/dc54fb7185a2d4da37308322ea1a7b992312030b2e37262de4eb4003f5c7/stackit_ske-1.12.0-py3-none-any.whl", hash = "sha256:45bd8084d87f14f818b3d7e824450248c8784ed204ca1b2dc108f491dcbdb1a3", size = 93142, upload-time = "2026-06-30T11:18:48.233Z" },
]
[[package]]
name = "std-uritemplate"
version = "2.0.8"