feat(permissions): emit the hello event from Terraform too

Adds the hello event emitter to the Terraform path, so both onboarding
templates verify the connection by themselves and leave the same footprint in
the account: a function whose only permissions are events:PutEvents on the
default bus and its own log group, invoked once on apply and again whenever the
endpoint changes.

The apply reports the outcome through the prowler_realtime_hello_status output,
and the function never raises, so a failed verification cannot fail the apply
that created the scan role.
This commit is contained in:
César Arroba committed 2026-08-17 11:55:19 +02:00
1 parent 56d33fe252
commit b5a23a2764
7 files changed
+150 -3

No files matched your search

+1
View File
@@ -134,6 +134,7 @@ ai-config.json
.terraform*
*.tfstate
*.tfstate.*
permissions/templates/terraform/**/*.zip
# .env
ui/.env*
+4 -3
View File
@@ -55,7 +55,9 @@ 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.
To verify the connection without touching any real resource, emit the hello event yourself. 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 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.
To re-check the connection at any point, emit it yourself:
```bash
aws events put-events --entries '[{
@@ -65,8 +67,6 @@ aws events put-events --entries '[{
}]'
```
The CloudFormation template emits this event by itself on deploy; here it is a manual step, because a Terraform apply already runs from a shell with credentials and adding a Lambda to the account just to send one event is a worse trade.
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.
@@ -93,5 +93,6 @@ 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
> **Note:** Terraform will use the AWS credentials of your default profile or AWS_PROFILE environment variable.
@@ -40,3 +40,8 @@ output "prowler_realtime_dlq_url" {
description = "URL of the dead-letter queue holding the events EventBridge could not deliver (null if real-time detection is disabled)"
value = try(module.realtime_detection[0].prowler_realtime_dlq_url, null)
}
output "prowler_realtime_hello_status" {
description = "Result of the hello event emitted on apply (null if real-time detection is disabled)"
value = try(module.realtime_detection[0].prowler_realtime_hello_status, null)
}
@@ -0,0 +1,45 @@
"""Emits 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
fail the apply that created the scan role.
"""
import json
import time
import boto3
ATTEMPTS = 3
BACKOFF_SECONDS = 2
def handler(_event, context):
_, _, _, region, account = context.invoked_function_arn.split(":")[:5]
entries = [
{
"Source": "prowler.simulation",
"DetailType": "test_connection",
"Detail": json.dumps(
{"account_id": account, "region": region, "trigger": "terraform"}
),
"EventBusName": "default",
}
]
client = boto3.client("events")
error = "not attempted"
# The role policy may not be in effect yet on the first apply
for attempt in range(ATTEMPTS):
try:
entry = client.put_events(Entries=entries)["Entries"][0]
if not entry.get("ErrorCode"):
return {"status": "Sent", "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}")
return {"status": "Failed", "error": error}
@@ -208,3 +208,87 @@ resource "aws_cloudwatch_event_target" "prowler_realtime_hello" {
arn = aws_sqs_queue.prowler_realtime_dlq.arn
}
}
# Hello event emitter, the same function the CloudFormation template runs, so both
# onboarding paths leave the same footprint in the account
###################################
data "archive_file" "prowler_realtime_hello" {
type = "zip"
source_file = "${path.module}/hello.py"
output_path = "${path.module}/hello.zip"
}
# Declared explicitly so the function does not leave a log group with unlimited retention behind
resource "aws_cloudwatch_log_group" "prowler_realtime_hello" {
name = "/aws/lambda/ProwlerRealtimeHello"
retention_in_days = 30
}
data "aws_iam_policy_document" "prowler_realtime_hello_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "prowler_realtime_hello" {
name = "ProwlerRealtimeHello"
assume_role_policy = data.aws_iam_policy_document.prowler_realtime_hello_assume_role.json
}
resource "aws_iam_role_policy" "prowler_realtime_hello" {
name = "PutHelloEvent"
role = aws_iam_role.prowler_realtime_hello.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "events:PutEvents"
Resource = "arn:${data.aws_partition.current.partition}:events:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:event-bus/default"
},
{
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "${aws_cloudwatch_log_group.prowler_realtime_hello.arn}:*"
},
]
})
}
resource "aws_lambda_function" "prowler_realtime_hello" {
function_name = "ProwlerRealtimeHello"
description = "Emits the Prowler real-time detection hello event"
role = aws_iam_role.prowler_realtime_hello.arn
runtime = "python3.13"
handler = "hello.handler"
timeout = 30
filename = data.archive_file.prowler_realtime_hello.output_path
source_code_hash = data.archive_file.prowler_realtime_hello.output_base64sha256
depends_on = [aws_cloudwatch_log_group.prowler_realtime_hello]
}
# Emitting the event is the last step: the rule, the target and every policy it
# depends on must already exist, and none of them is referenced here
resource "aws_lambda_invocation" "prowler_realtime_hello" {
function_name = aws_lambda_function.prowler_realtime_hello.function_name
input = "{}"
# Re-emits the event when the endpoint changes, like the CloudFormation custom resource
triggers = {
webhook_url = var.prowler_webhook_url
}
depends_on = [
aws_cloudwatch_event_target.prowler_realtime_hello,
aws_iam_role_policy.prowler_realtime_hello,
aws_iam_role_policy.prowler_realtime_invoke,
aws_sqs_queue_policy.prowler_realtime_dlq,
]
}
@@ -18,6 +18,11 @@ output "prowler_realtime_dlq_arn" {
value = aws_sqs_queue.prowler_realtime_dlq.arn
}
output "prowler_realtime_hello_status" {
description = "Result of the hello event emitted on apply: Sent, or Failed with the error"
value = try(jsondecode(aws_lambda_invocation.prowler_realtime_hello.result), null)
}
output "prowler_realtime_invoke_role_arn" {
description = "ARN of the IAM role assumed by EventBridge to invoke the API destination"
value = aws_iam_role.prowler_realtime_invoke.arn
@@ -1,3 +1,9 @@
terraform {
required_version = ">= 1.5"
required_providers {
archive = {
source = "hashicorp/archive"
version = "~> 2.4"
}
}
}