fix(permissions): address review on the real-time detection templates

Constrains the catalog rule to genuine AWS service events with a source prefix,
which PutEvents cannot forge, so a caller inside the account can no longer inject
a CloudTrail-shaped event into the Prowler pipeline.

Makes the Terraform deploy region an input instead of a hardcoded us-east-1,
which contradicted a README that told users to deploy once per region, and adds
the region to the manual put-events command so it reaches the right bus.

Renames the hello event status to Published and reworks the wording around it:
EventBridge accepting an event is not the endpoint receiving it, and Prowler
Cloud is what confirms the connection. Documents that Terraform writes the API
key to state, so the key belongs in a secret manager and the state in an
encrypted remote backend.
This commit is contained in:
César Arroba
2026-08-18 18:02:15 +02:00
parent b5a23a2764
commit 580a0436ae
9 changed files with 42 additions and 13 deletions
@@ -731,6 +731,10 @@ Resources:
Description: Forwards the CloudTrail management events tracked by Prowler real-time detection to Prowler Cloud
State: ENABLED
EventPattern:
# PutEvents rejects the reserved aws. prefix, so this keeps a caller in the
# account from forging a CloudTrail-shaped event
source:
- prefix: "aws."
detail-type:
- "AWS API Call via CloudTrail"
detail:
@@ -915,7 +919,7 @@ Resources:
def handler(event, context):
"""Emit the hello event, then always report SUCCESS.
"""Publish the hello event, then always report SUCCESS.
A failed verification must never roll back the scan role, so any
error is reported in the response data instead of failing the stack.
@@ -944,7 +948,7 @@ Resources:
if entry.get("ErrorCode"):
data = {"Status": "Failed", "Error": entry["ErrorCode"]}
else:
data = {"Status": "Sent", "EventId": entry["EventId"]}
data = {"Status": "Published", "EventId": entry["EventId"]}
except Exception as error: # noqa: BLE001
data = {"Status": "Failed", "Error": str(error)[:200]}
print(json.dumps(data))
+17 -5
View File
@@ -20,6 +20,7 @@ This Terraform configuration creates the necessary IAM role and policies to allo
### Variables
- `external_id` (required): External ID for role assumption security
- `region` (optional): AWS region to deploy to (default: `us-east-1`). The EventBridge rules are regional, so deploy once per region you want covered
- `account_id` (optional): AWS Account ID that will assume the role (defaults to Prowler Cloud: "232136659152")
- `iam_principal` (optional): IAM principal pattern allowed to assume the role (defaults to Prowler Cloud: "role/prowler*")
- `enable_s3_integration` (optional): Enable S3 integration for storing scan reports (default: false)
@@ -55,12 +56,14 @@ terraform apply \
`prowler_webhook_url` already defaults to the Prowler Cloud ingest endpoint, so only the API key is needed. Override it for a self-hosted deployment or for testing.
The apply verifies the connection by emitting a hello event, without touching any real resource. It travels the same connection, API destination, API key and endpoint as a real event, and Prowler Cloud marks the provider as connected without running a scan. The `prowler_realtime_hello_status` output reports the result, and the event is emitted again whenever `prowler_webhook_url` changes.
The apply publishes a hello event to verify the connection, without touching any real resource. It travels the same connection, API destination, API key and endpoint as a real event, and Prowler Cloud marks the provider as connected once it arrives, without running a scan.
To re-check the connection at any point, emit it yourself:
The `prowler_realtime_hello_status` output reports only that EventBridge accepted the event, which is not the same as the endpoint receiving it: delivery is asynchronous. Prowler Cloud is what confirms the connection, and a delivery that fails every retry lands in the dead-letter queue below. The event is published again whenever `prowler_webhook_url` changes.
To re-check the connection at any point, publish it yourself. Use the same region the template deploys to, since the rule only exists on that region's event bus:
```bash
aws events put-events --entries '[{
aws events put-events --region us-east-1 --entries '[{
"Source": "prowler.simulation",
"DetailType": "test_connection",
"Detail": "{}"
@@ -69,7 +72,7 @@ aws events put-events --entries '[{
Failed deliveries are not lost: EventBridge retries for up to 24 hours and then writes the event to the `ProwlerRealtimeDetectionDLQ` queue created in your account, together with the error code and the number of attempts. Responses that are never retried (any 4xx other than 401, 407, 409 and 429) land there on the first attempt. The queue is yours: Prowler has no permission to read it.
> **Note:** the EventBridge rule is regional. It forwards only the events delivered to the default event bus of the region Terraform deploys to (`us-east-1` by default, see `versions.tf`). IAM events are global and always land in `us-east-1`, but regional services (EC2 security groups, RDS, per-region Config and GuardDuty) are only covered in that region. Deploy the module in every region you want covered.
> **Note:** the EventBridge rules are regional. They forward only the events delivered to the default event bus of the region set in `region` (`us-east-1` by default). IAM events are global and always land in `us-east-1`, but regional services (EC2 security groups, RDS, per-region Config and GuardDuty) are only covered in the region you deploy to. Run the template once per region you want covered, changing `region` each time.
#### Using terraform.tfvars file (Recommended)
```bash
@@ -93,6 +96,15 @@ After successful deployment, you'll get:
- `prowler_realtime_rule_arn`: ARN of the EventBridge rule (null if real-time detection is disabled)
- `prowler_realtime_api_destination_arn`: ARN of the EventBridge API destination (null if real-time detection is disabled)
- `prowler_realtime_dlq_url`: URL of the dead-letter queue (null if real-time detection is disabled)
- `prowler_realtime_hello_status`: result of the hello event emitted on apply, `Sent` or `Failed` with the error
- `prowler_realtime_hello_status`: whether EventBridge accepted the hello event on apply, `Published` or `Failed` with the error
### Handling the API key
Terraform writes every variable it is given to state, including `prowler_api_key`, and marking it `sensitive` only hides it from the CLI output. Before enabling real-time detection:
- Configure an encrypted, access-controlled remote backend (S3 with SSE and a restrictive bucket policy, Terraform Cloud, or equivalent). The default local state is a plaintext file in your working directory.
- Pass the key from your secret manager instead of typing it into a file, for example `export TF_VAR_prowler_api_key="$(your-secret-tool read prowler/api-key)"`.
- Do not commit a populated `terraform.tfvars`, and treat plan files as secrets too.
- Revoking the key in Prowler Cloud stops all ingestion, so rotate it there if a state file is ever exposed.
> **Note:** Terraform will use the AWS credentials of your default profile or AWS_PROFILE environment variable.
+1 -1
View File
@@ -42,6 +42,6 @@ output "prowler_realtime_dlq_url" {
}
output "prowler_realtime_hello_status" {
description = "Result of the hello event emitted on apply (null if real-time detection is disabled)"
description = "Whether the hello event was accepted by EventBridge on apply (null if real-time detection is disabled)"
value = try(module.realtime_detection[0].prowler_realtime_hello_status, null)
}
@@ -1,4 +1,4 @@
"""Emits the Prowler real-time detection hello event.
"""Publishes the Prowler real-time detection hello event.
Invoked once by Terraform when real-time detection is enabled, mirroring the
CloudFormation custom resource. It never raises: a failed verification must not
@@ -34,12 +34,12 @@ def handler(_event, context):
try:
entry = client.put_events(Entries=entries)["Entries"][0]
if not entry.get("ErrorCode"):
return {"status": "Sent", "event_id": entry["EventId"]}
return {"status": "Published", "event_id": entry["EventId"]}
error = entry["ErrorCode"]
except Exception as failure: # noqa: BLE001
error = str(failure)[:200]
if attempt + 1 < ATTEMPTS:
time.sleep(BACKOFF_SECONDS)
print(f"hello event not sent: {error}")
print(f"hello event not published: {error}")
return {"status": "Failed", "error": error}
@@ -115,6 +115,9 @@ resource "aws_cloudwatch_event_rule" "prowler_realtime" {
state = "ENABLED"
event_pattern = jsonencode({
# PutEvents rejects the reserved aws. prefix, so this keeps a caller in the
# account from forging a CloudTrail-shaped event
source = [{ prefix = "aws." }]
"detail-type" = ["AWS API Call via CloudTrail"]
detail = {
eventSource = [
@@ -19,7 +19,7 @@ output "prowler_realtime_dlq_arn" {
}
output "prowler_realtime_hello_status" {
description = "Result of the hello event emitted on apply: Sent, or Failed with the error"
description = "Whether the hello event was accepted by EventBridge on apply. Prowler Cloud confirms the connection when the event reaches the endpoint"
value = try(jsondecode(aws_lambda_invocation.prowler_realtime_hello.result), null)
}
@@ -16,6 +16,10 @@ external_id = "your-unique-external-id-here"
# IAM Principal Pattern (leave default unless using self-hosted)
# iam_principal = "role/prowler*"
# AWS region to deploy to. The real-time detection rules are regional:
# run the template once per region you want covered.
# region = "us-east-1"
# =============================================================================
# S3 Integration Configuration
# =============================================================================
@@ -27,6 +27,12 @@ variable "iam_principal" {
default = "role/prowler*"
}
variable "region" {
type = string
description = "AWS region to deploy to. Only relevant for real-time detection: the EventBridge rules are regional, so deploy once per region you want covered."
default = "us-east-1"
}
variable "enable_organizations" {
type = bool
description = "Enable AWS Organizations discovery permissions. Set to true only when deploying this role in the management account."
+1 -1
View File
@@ -11,7 +11,7 @@ terraform {
}
provider "aws" {
region = "us-east-1"
region = var.region
default_tags {
tags = {
"Name" = "ProwlerScan",