feat(permissions): emit a hello event to verify real-time detection

Adds the synthetic hello event from the MVP onboarding flow, so the connection
can be verified end to end without changing any real resource. A dedicated rule
matches source prowler.simulation and detail-type test_connection, since no
CloudTrail event pattern would, and forwards it through the same connection,
API destination, API key and endpoint as a real event.

In CloudFormation a custom resource emits the event on create and update. Its
function only holds events:PutEvents on the default bus plus its own log group,
and always answers SUCCESS: a failed verification must never roll back the scan
role. Terraform documents the equivalent put-events command instead, because an
apply already runs from a shell with credentials.

The invoke role trust policy and the dead-letter queue policy now allow both
rule ARNs; scoped to a single rule, the hello event would have been dropped
with no explanation.
This commit is contained in:
César Arroba committed 2026-08-17 11:24:00 +02:00
1 parent 29102e7b33
commit 56d33fe252
3 files changed
+225 -7

No files matched your search

@@ -660,8 +660,10 @@ Resources:
StringEquals:
"aws:SourceAccount": !Ref AWS::AccountId
ArnLike:
# Built from the rule name to avoid a circular dependency with the rule
"aws:SourceArn": !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/ProwlerRealtimeDetection"
# Built from the rule names to avoid a circular dependency with the rules
"aws:SourceArn":
- !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/ProwlerRealtimeDetection"
- !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/ProwlerRealtimeDetectionHello"
Policies:
- PolicyName: InvokeApiDestination
PolicyDocument:
@@ -716,8 +718,10 @@ Resources:
Resource: !GetAtt ProwlerRealtimeDlq.Arn
Condition:
ArnEquals:
# Built from the rule name to avoid a circular dependency with the rule
"aws:SourceArn": !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/ProwlerRealtimeDetection"
# Built from the rule names to avoid a circular dependency with the rules
"aws:SourceArn":
- !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/ProwlerRealtimeDetection"
- !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/ProwlerRealtimeDetectionHello"
ProwlerRealtimeRule:
Type: AWS::Events::Rule
@@ -790,6 +794,174 @@ Resources:
- Key: "Name"
Value: "ProwlerRealtimeDetection"
# Carries the synthetic hello event, which no CloudTrail pattern would match
ProwlerRealtimeHelloRule:
Type: AWS::Events::Rule
Condition: RealtimeDetectionEnabled
Properties:
Name: ProwlerRealtimeDetectionHello
Description: Forwards the Prowler real-time detection hello event used to verify the connection
State: ENABLED
EventPattern:
source:
- prowler.simulation
detail-type:
- test_connection
Targets:
- Id: ProwlerCloud
Arn: !GetAtt ProwlerRealtimeApiDestination.Arn
RoleArn: !GetAtt ProwlerRealtimeInvokeRole.Arn
RetryPolicy:
MaximumEventAgeInSeconds: 86400
MaximumRetryAttempts: 185
DeadLetterConfig:
Arn: !GetAtt ProwlerRealtimeDlq.Arn
Tags:
- Key: "Service"
Value: "https://prowler.com"
- Key: "Support"
Value: "support@prowler.com"
- Key: "CloudFormation"
Value: "true"
- Key: "Name"
Value: "ProwlerRealtimeDetectionHello"
# Explicit log group so the function does not leave one with unlimited retention behind
ProwlerRealtimeHelloLogGroup:
Type: AWS::Logs::LogGroup
Condition: RealtimeDetectionEnabled
Properties:
LogGroupName: /aws/lambda/ProwlerRealtimeHello
RetentionInDays: 30
ProwlerRealtimeHelloRole:
Type: AWS::IAM::Role
Condition: RealtimeDetectionEnabled
Properties:
RoleName: ProwlerRealtimeHello
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: "sts:AssumeRole"
Policies:
- PolicyName: PutHelloEvent
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: "events:PutEvents"
Resource: !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:event-bus/default"
- Effect: Allow
Action:
- "logs:CreateLogStream"
- "logs:PutLogEvents"
Resource: !GetAtt ProwlerRealtimeHelloLogGroup.Arn
Tags:
- Key: "Service"
Value: "https://prowler.com"
- Key: "Support"
Value: "support@prowler.com"
- Key: "CloudFormation"
Value: "true"
- Key: "Name"
Value: "ProwlerRealtimeHello"
ProwlerRealtimeHelloFunction:
Type: AWS::Lambda::Function
Condition: RealtimeDetectionEnabled
Properties:
FunctionName: ProwlerRealtimeHello
Description: Emits the Prowler real-time detection hello event when the stack is created or updated
Runtime: python3.13
Handler: index.handler
Timeout: 30
Role: !GetAtt ProwlerRealtimeHelloRole.Arn
Code:
ZipFile: |
import json
import urllib.request
import boto3
def respond(event, context, data):
"""Answer CloudFormation. Always SUCCESS: see handler()."""
body = json.dumps(
{
"Status": "SUCCESS",
"Reason": f"See CloudWatch log stream {context.log_stream_name}",
"PhysicalResourceId": "ProwlerRealtimeHelloEvent",
"StackId": event["StackId"],
"RequestId": event["RequestId"],
"LogicalResourceId": event["LogicalResourceId"],
"Data": data,
}
).encode()
request = urllib.request.Request(
event["ResponseURL"],
data=body,
method="PUT",
headers={"content-type": "", "content-length": str(len(body))},
)
for attempt in range(3):
try:
urllib.request.urlopen(request, timeout=10)
return
except Exception as error: # noqa: BLE001
print(f"response attempt {attempt + 1} failed: {error}")
def handler(event, context):
"""Emit 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.
"""
data = {"Status": "Skipped"}
try:
if event["RequestType"] in ("Create", "Update"):
_, _, _, region, account = context.invoked_function_arn.split(":")[:5]
entry = boto3.client("events").put_events(
Entries=[
{
"Source": "prowler.simulation",
"DetailType": "test_connection",
"Detail": json.dumps(
{
"account_id": account,
"region": region,
"stack_id": event["StackId"],
"request_type": event["RequestType"],
}
),
"EventBusName": "default",
}
]
)["Entries"][0]
if entry.get("ErrorCode"):
data = {"Status": "Failed", "Error": entry["ErrorCode"]}
else:
data = {"Status": "Sent", "EventId": entry["EventId"]}
except Exception as error: # noqa: BLE001
data = {"Status": "Failed", "Error": str(error)[:200]}
print(json.dumps(data))
respond(event, context, data)
# Emitting the event is the last step: the rule, destination and DLQ must already exist
ProwlerRealtimeHelloTrigger:
Type: Custom::ProwlerHelloEvent
Condition: RealtimeDetectionEnabled
DependsOn:
- ProwlerRealtimeHelloRule
- ProwlerRealtimeDlqPolicy
Properties:
ServiceToken: !GetAtt ProwlerRealtimeHelloFunction.Arn
# Re-emits the event when the endpoint changes; the API key is deliberately not passed in
WebhookUrl: !Ref ProwlerWebhookUrl
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
+12
View File
@@ -55,6 +55,18 @@ 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:
```bash
aws events put-events --entries '[{
"Source": "prowler.simulation",
"DetailType": "test_connection",
"Detail": "{}"
}]'
```
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.
@@ -40,11 +40,13 @@ data "aws_iam_policy_document" "prowler_realtime_assume_role" {
values = [data.aws_caller_identity.current.account_id]
}
# Built from the rule name to avoid a circular dependency with the rule
condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:${data.aws_partition.current.partition}:events:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:rule/ProwlerRealtimeDetection"]
values = [
aws_cloudwatch_event_rule.prowler_realtime.arn,
aws_cloudwatch_event_rule.prowler_realtime_hello.arn,
]
}
}
}
@@ -92,7 +94,10 @@ data "aws_iam_policy_document" "prowler_realtime_dlq" {
condition {
test = "ArnEquals"
variable = "aws:SourceArn"
values = [aws_cloudwatch_event_rule.prowler_realtime.arn]
values = [
aws_cloudwatch_event_rule.prowler_realtime.arn,
aws_cloudwatch_event_rule.prowler_realtime_hello.arn,
]
}
}
}
@@ -174,3 +179,32 @@ resource "aws_cloudwatch_event_target" "prowler_realtime" {
arn = aws_sqs_queue.prowler_realtime_dlq.arn
}
}
# Rule carrying the synthetic hello event, which no CloudTrail pattern would match
###################################
resource "aws_cloudwatch_event_rule" "prowler_realtime_hello" {
name = "ProwlerRealtimeDetectionHello"
description = "Forwards the Prowler real-time detection hello event used to verify the connection"
state = "ENABLED"
event_pattern = jsonencode({
source = ["prowler.simulation"]
"detail-type" = ["test_connection"]
})
}
resource "aws_cloudwatch_event_target" "prowler_realtime_hello" {
rule = aws_cloudwatch_event_rule.prowler_realtime_hello.name
target_id = "ProwlerCloud"
arn = aws_cloudwatch_event_api_destination.prowler_realtime.arn
role_arn = aws_iam_role.prowler_realtime_invoke.arn
retry_policy {
maximum_event_age_in_seconds = 86400
maximum_retry_attempts = 185
}
dead_letter_config {
arn = aws_sqs_queue.prowler_realtime_dlq.arn
}
}