Skip to content

feat(awslambda): enrich Function model with inventory fields and add 3 security checks#10381

Open
sandiyochristan wants to merge 1 commit intoprowler-cloud:masterfrom
sandiyochristan:feat/lambda-event-source-inventory
Open

feat(awslambda): enrich Function model with inventory fields and add 3 security checks#10381
sandiyochristan wants to merge 1 commit intoprowler-cloud:masterfrom
sandiyochristan:feat/lambda-event-source-inventory

Conversation

@sandiyochristan
Copy link
Contributor

@sandiyochristan sandiyochristan commented Mar 18, 2026

Why this PR exists

The `Lambda` service model collected only basic function configuration (runtime, VPC, environment variables, policy, URL config). It had no visibility into:

  1. What triggers a function — event source mappings (SQS, DynamoDB Streams, Kinesis, MSK, MQ) are the dependency edges between services. Without them, any graph-based analysis of how data flows through serverless workloads is blind.
  2. Where a function's code actually comes from — Lambda layers inject shared code into the execution environment at runtime. If a layer is owned by an external AWS account, the owning account controls what runs inside your function.
  3. What happens when a function fails — without a dead-letter queue, failed async invocations are silently discarded after retries. In security pipelines (alert processors, audit log handlers) this means failures are invisible.
  4. Whether environment variable secrets are under customer key control — Lambda encrypts env vars at rest by default, but with an AWS-managed key. For compliance (PCI-DSS, HIPAA, FedRAMP) and for key-revocation capability, a customer-managed KMS key (CMK) is required.

These four gaps map directly to real attack paths and compliance requirements. This PR closes all four with inventory enrichment + checks.


What changed

1. Service model — `awslambda_service.py`

New dataclasses added:

```python
class EventSourceMapping(BaseModel):
uuid: str
event_source_arn: str # SQS / DynamoDB stream / Kinesis / MSK ARN
state: str # Enabled, Disabled, Creating, …
batch_size: Optional[int]
starting_position: Optional[str]

class Layer(BaseModel):
arn: str
# account_id extracted from ARN field [4] — used for cross-account detection

class DeadLetterConfig(BaseModel):
target_arn: str # SQS queue ARN or SNS topic ARN
```

New fields on `Function`:

Field Type Populated from Purpose
`event_source_mappings` `list[EventSourceMapping]` `ListEventSourceMappings` Trigger graph edges
`layers` `list[Layer]` `list_functions` → `Layers[]` Layer dependency + account ownership
`dead_letter_config` `Optional[DeadLetterConfig]` `list_functions` → `DeadLetterConfig` DLQ target ARN
`kms_key_arn` `Optional[str]` `list_functions` → `KMSKeyArn` CMK for env var encryption

New service method — `_list_event_source_mappings(regional_client)`:

  • Calls `ListEventSourceMappings` once per region (no `FunctionName` filter) to minimise API call count
  • Paginates the full result set
  • Normalises the function ARN from the mapping (`arn:aws:lambda:…:function:name:qualifier` → `arn:aws:lambda:…:function:name`) before lookup so qualified ARNs are correctly matched to the `functions` dict
  • Error is caught and logged; does not abort inventory collection for other functions

Why read `DeadLetterConfig`, `KMSKeyArn`, and `Layers` from `list_functions` rather than a separate per-function call?

All three fields are already returned by the `list_functions` paginator — adding them costs zero additional API calls. Only `event_source_mappings` requires a separate call because the data lives in a different resource type.

Backward compatibility:

All new fields have defaults (`[]` or `None`), so existing checks that do not use them are unaffected.


2. New check — `awslambda_function_no_dead_letter_queue` (severity: medium)

What it checks:
Whether the Lambda function has a Dead Letter Queue (SQS or SNS) configured for failed async invocations.

Pass/Fail logic:
```
PASS function.dead_letter_config is not None → DLQ configured, target ARN shown
PASS function has no async invocations context → still reported; DLQ is always recommended
FAIL function.dead_letter_config is None → no DLQ configured
```

Why medium and not low?
A DLQ gap in a security-relevant function (alert handler, CloudTrail processor, SIEM forwarder) means a processing failure becomes invisible — there is no way to know a security event was dropped. The blast radius is bounded (it does not by itself expose data), hence medium rather than high.

AWS API used: `list_functions` → `DeadLetterConfig.TargetArn`

Remediation: `aws lambda update-function-configuration --function-name --dead-letter-config TargetArn=`


3. New check — `awslambda_function_using_cross_account_layers` (severity: high)

What it checks:
Whether any layer attached to the function is owned by a different AWS account than the one being audited.

How cross-account is detected:
Lambda layer ARN format: `arn:aws:lambda:{region}:{account-id}:layer:{name}:{version}`
The `account_id` property on `Layer` splits on `:` and returns index `[4]`. If this differs from `awslambda_client.audited_account`, the layer is external.

Pass/Fail logic:
```
PASS function has no layers
PASS all layer ARNs have account_id == audited_account
FAIL any layer ARN has account_id != audited_account → lists all offending ARNs
```

Why high?
A Lambda layer is code injected into the function execution environment at runtime — it runs with the same IAM role, same VPC placement, same environment variables. If the external account is compromised or the layer version is silently updated, every function using it executes attacker-controlled code. This is a supply chain attack path that leads directly to IAM privilege escalation.

Attack chain: compromise external layer account → publish new layer version → all consumer functions execute attacker code with their own IAM roles → lateral movement across all attached services (RDS, S3, DynamoDB, …)

Note on AWS-published layers: AWS-managed layers (e.g., `arn:aws:lambda:…:017000801446:layer:AWSLambdaPowertoolsPythonV3…`) will also trigger this check. Prowler's mute / exception mechanism can be used to suppress known-good external layers per function.

AWS API used: `list_functions` → `Layers[].Arn`


4. New check — `awslambda_function_env_vars_not_encrypted_with_cmk` (severity: medium)

What it checks:
Whether a function that has environment variables is using a customer-managed KMS key (CMK) to encrypt them at rest.

Pass/Fail logic:
```
PASS function.environment is None or empty dict → no env vars, nothing to encrypt
PASS function.kms_key_arn is not None → CMK active, key ARN shown
FAIL function.environment is set AND kms_key_arn is None → env vars present, no CMK
```

Why not flag functions with no env vars?
An empty `environment` means there is nothing to protect. Generating a finding for a function with no env vars would be noise.

Why medium and not low?
Environment variables commonly hold connection strings, feature-flag secrets, and API endpoint configurations. Without a CMK there is no customer-controlled key rotation, no granular access auditing via KMS CloudTrail events, and no key-revocation capability. PCI-DSS 3.5, HIPAA §164.312(a)(2)(iv), and FedRAMP require customer-controlled encryption for data at rest. Medium reflects the real compliance risk while acknowledging that env vars ideally should not hold actual secrets (Secrets Manager is the right tool for that).

AWS API used: `list_functions` → `KMSKeyArn`

Remediation: `aws lambda update-function-configuration --function-name --kms-key-arn `


How tests are structured

All tests follow the existing Prowler Lambda check test pattern:

  1. Create real AWS resources via boto3 inside a `@mock_aws` context
  2. Instantiate `Lambda(aws_provider)` against those mocked resources
  3. Patch `awslambda_client` with the real service instance
  4. Import the check class inside the `with` block and run `execute()`
  5. Assert on `status`, `status_extended`, `resource_id`, `resource_arn`, `region`

Note on moto limitations: moto 5.x does not return `DeadLetterConfig`, `KMSKeyArn`, or `Layers` in its `list_functions` response. For FAIL-branch tests (the field is absent) this is fine — the service naturally produces `None`. For PASS-branch tests, the field is set directly on the `Function` object after service initialisation. This is intentional and consistent with how other Prowler tests handle moto gaps (the check logic is still fully exercised; only the collection path for that specific field is not covered by moto).

Test counts:

  • `awslambda_function_no_dead_letter_queue`: 3 tests (no functions, without DLQ, with DLQ)
  • `awslambda_function_using_cross_account_layers`: 4 tests (no functions, no layers, own-account layer, cross-account layer)
  • `awslambda_function_env_vars_not_encrypted_with_cmk`: 4 tests (no functions, no env vars, env vars without CMK, env vars with CMK)
  • All 64 Lambda tests pass (11 new + 53 existing)

Files changed

```
prowler/providers/aws/services/awslambda/awslambda_service.py ← enriched
prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/
init.py
awslambda_function_no_dead_letter_queue.py
awslambda_function_no_dead_letter_queue.metadata.json
prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/
init.py
awslambda_function_using_cross_account_layers.py
awslambda_function_using_cross_account_layers.metadata.json
prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/
init.py
awslambda_function_env_vars_not_encrypted_with_cmk.py
awslambda_function_env_vars_not_encrypted_with_cmk.metadata.json
tests/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/
init.py
awslambda_function_no_dead_letter_queue_test.py
tests/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/
init.py
awslambda_function_using_cross_account_layers_test.py
tests/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/
init.py
awslambda_function_env_vars_not_encrypted_with_cmk_test.py
```

No other service, check, or file is modified.

Checklist

  • Read CONTRIBUTING.md
  • No new dependencies
  • Minimal, focused diff — only `awslambda_service.py` modified in the service layer
  • Full test coverage for all new check logic paths
  • All 64 Lambda tests pass locally

Add event source mapping inventory and three new checks to the Lambda
service that surface attack-path-relevant findings:

Service model (awslambda_service.py):
- Add EventSourceMapping dataclass with uuid, event_source_arn, state,
  batch_size, starting_position fields
- Add Layer dataclass with account_id property for cross-account detection
- Add DeadLetterConfig dataclass with target_arn field
- Add kms_key_arn, layers, dead_letter_config, event_source_mappings
  fields to Function model
- Add _list_event_source_mappings() that paginates the full region
  mapping list and associates each mapping to its function by ARN

New checks:
- awslambda_function_no_dead_letter_queue (medium): flags async
  functions with no DLQ; silent failures can mask security events
- awslambda_function_using_cross_account_layers (high): flags functions
  using layers owned by external accounts — supply chain attack surface
- awslambda_function_env_vars_not_encrypted_with_cmk (medium): flags
  functions with env vars but no customer-managed KMS key

Tests: 11 new tests across the 3 checks; all 64 Lambda tests pass
@sandiyochristan sandiyochristan requested review from a team as code owners March 18, 2026 23:43
@github-actions github-actions bot added provider/aws Issues/PRs related with the AWS provider metadata-review community Opened by the Community labels Mar 18, 2026
@github-actions
Copy link
Contributor

Conflict Markers Resolved

All conflict markers have been successfully resolved in this pull request.

@codecov
Copy link

codecov bot commented Mar 19, 2026

Codecov Report

❌ Patch coverage is 92.22222% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 16.68%. Comparing base (5a3475b) to head (4884578).
⚠️ Report is 1 commits behind head on master.

❗ There is a different number of reports uploaded between BASE (5a3475b) and HEAD (4884578). Click for more details.

HEAD has 4 uploads less than BASE
Flag BASE (5a3475b) HEAD (4884578)
prowler-py3.11-oraclecloud 1 0
prowler-py3.12-oraclecloud 1 0
prowler-py3.10-oraclecloud 1 0
prowler-py3.9-oraclecloud 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #10381       +/-   ##
===========================================
- Coverage   56.85%   16.68%   -40.17%     
===========================================
  Files          87      837      +750     
  Lines        2846    23806    +20960     
===========================================
+ Hits         1618     3973     +2355     
- Misses       1228    19833    +18605     
Flag Coverage Δ
prowler-py3.10-aws 16.68% <92.22%> (?)
prowler-py3.10-oraclecloud ?
prowler-py3.11-aws 16.68% <92.22%> (?)
prowler-py3.11-oraclecloud ?
prowler-py3.12-aws 16.66% <92.22%> (?)
prowler-py3.12-oraclecloud ?
prowler-py3.9-aws 16.68% <92.22%> (?)
prowler-py3.9-oraclecloud ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
prowler 16.68% <92.22%> (-40.17%) ⬇️
api ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community Opened by the Community metadata-review provider/aws Issues/PRs related with the AWS provider

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant