Skip to content

Commit cabd4c7

Browse files
committed
feat(access-control): detect cross-account async destinations and alias traffic shadowing
Two native-feature abuse paths went unchecked. First, async-invoke destinations: an attacker who can call lambda:PutFunctionEventInvokeConfig points a function's OnSuccess/OnFailure destination at an SQS queue, SNS topic, Lambda, or EventBridge bus in their own account and exfiltrates every async invocation result or payload, and the config keeps firing after their access is revoked. Second, alias traffic shadowing: a weighted alias (RoutingConfig.AdditionalVersionWeights) quietly routes a fraction of invocations to a second, attacker-published version while the primary version still looks clean, a stealthy persistence and backdoor technique. check_destinations reads the function event-invoke config, parses each destination ARN, compares its account against the scanning account, and reports has_external_destination plus external_destinations. A CRITICAL external_account_destination finding is raised and the function score is reduced by 20. check_aliases lists aliases and flags any with an additional-version routing config, reporting shadowed_aliases; a MEDIUM alias_traffic_shadowing finding is raised and the score reduced by 5. Same-account destinations and plain (unweighted) aliases are unaffected. Adds five tests across the two checks. Bumps the check count to 21 and the version to 1.0.2.
1 parent 2d21a9c commit cabd4c7

8 files changed

Lines changed: 277 additions & 9 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ FROM python:3.11-slim-bookworm
22

33
LABEL maintainer="Toc Consulting <[email protected]>"
44
LABEL description="AWS Lambda security scanner with multi-framework compliance mapping"
5-
LABEL version="1.0.1"
5+
LABEL version="1.0.2"
66

77
ENV PYTHONDONTWRITEBYTECODE=1
88
ENV PYTHONUNBUFFERED=1

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<a href="https://aws.amazon.com/lambda/"><img src="https://img.shields.io/badge/AWS-Lambda-orange.svg" alt="AWS"></a>
1313
</p>
1414

15-
A comprehensive AWS Lambda security scanner with 19 security checks across 5 categories and compliance mapping for 10 frameworks (81 controls). Features multi-threaded scanning, secret detection in environment variables, and interactive HTML dashboards.
15+
A comprehensive AWS Lambda security scanner with 21 security checks across 5 categories and compliance mapping for 10 frameworks (81 controls). Features multi-threaded scanning, secret detection in environment variables, and interactive HTML dashboards.
1616

1717
<p align="center">
1818
<img src="https://raw.githubusercontent.com/TocConsulting/lambda-security-scanner/main/assets/demo.gif" alt="Lambda Security Scanner demo: secrets, public URLs, IAM, and multi-framework compliance" width="100%">
@@ -22,7 +22,7 @@ A comprehensive AWS Lambda security scanner with 19 security checks across 5 cat
2222

2323
### **Comprehensive Security Analysis**
2424
- **Function Configuration**: Deprecated runtime detection, timeout tuning, environment variable secret scanning, ephemeral storage, external layers, X-Ray tracing, dead letter queues
25-
- **Access Control**: Resource policy public access, function URL authentication, CORS wildcard origins, overly permissive execution roles, shared role detection
25+
- **Access Control**: Resource policy public access, function URL authentication, CORS wildcard origins, overly permissive execution roles, shared role detection, cross-account async-invoke destinations, alias traffic shadowing
2626
- **Network Security**: VPC configuration, multi-AZ deployment, unrestricted security group egress
2727
- **Logging & Monitoring**: CloudWatch log group validation, log retention policies, reserved concurrency
2828
- **Code & Supply Chain**: Code signing configuration, event source mapping failure destinations
@@ -139,7 +139,7 @@ lambda-security-scanner security -f json -q
139139

140140
## Security Checks
141141

142-
### 19 Checks Across 5 Categories
142+
### 21 Checks Across 5 Categories
143143

144144
| ID | Check | Severity | Category |
145145
|-----|------------------------------------------|-------------------|-----------------------|
@@ -155,6 +155,8 @@ lambda-security-scanner security -f json -q
155155
| B.3 | Function URL CORS allows all origins | HIGH | Access Control |
156156
| B.4 | Overly permissive execution role | CRITICAL/HIGH | Access Control |
157157
| B.5 | Shared execution role | HIGH | Access Control |
158+
| B.6 | Async-invoke destination to external account | CRITICAL | Access Control |
159+
| B.7 | Alias traffic shadowing (weighted alias) | MEDIUM | Access Control |
158160
| C.1 | No VPC configuration | LOW | Network Security |
159161
| C.2 | VPC single AZ | MEDIUM | Network Security |
160162
| C.3 | Unrestricted SG egress | MEDIUM | Network Security |
@@ -287,6 +289,8 @@ docker run --rm \
287289
"lambda:GetCodeSigningConfig",
288290
"lambda:GetFunctionConcurrency",
289291
"lambda:ListEventSourceMappings",
292+
"lambda:GetFunctionEventInvokeConfig",
293+
"lambda:ListAliases",
290294
"iam:ListAttachedRolePolicies",
291295
"iam:GetPolicy",
292296
"iam:GetPolicyVersion",
@@ -395,7 +399,7 @@ lambda_security_scanner/
395399
├── checks/ # Security check modules
396400
│ ├── base.py # BaseChecker (session factory, error handling)
397401
│ ├── function_config.py # A.1-A.7: Runtime, secrets, layers, tracing
398-
│ ├── access_control.py # B.1-B.5: Policies, URLs, roles
402+
│ ├── access_control.py # B.1-B.7: Policies, URLs, roles, destinations, aliases
399403
│ ├── network_security.py # C.1-C.3: VPC, AZ, security groups
400404
│ ├── logging_monitoring.py # D.1-D.2: Log groups, concurrency
401405
│ └── code_security.py # E.1-E.2: Code signing, ESM

lambda_security_scanner/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Lambda Security Scanner - Comprehensive AWS Lambda security
22
auditing tool with multi-framework compliance mapping."""
33

4-
__version__ = "1.0.1"
4+
__version__ = "1.0.2"
55
__author__ = "Toc Consulting"
66
__email__ = "[email protected]"
77

lambda_security_scanner/checks/access_control.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,3 +626,126 @@ def check_shared_role(
626626
"shared_count": count,
627627
"role_arn": role_arn,
628628
}
629+
630+
def check_destinations(
631+
self, function_name: str, region: str, account_id: str
632+
) -> Dict:
633+
"""B.6: Check async-invoke destinations for external accounts.
634+
635+
An attacker who can call PutFunctionEventInvokeConfig points a
636+
function's OnSuccess/OnFailure destination (SQS, SNS, Lambda, or
637+
EventBridge) at a target in their own account, exfiltrating every
638+
async invocation result or payload. The configuration keeps firing
639+
after the attacker's access is revoked.
640+
641+
Args:
642+
function_name: Lambda function name or ARN.
643+
region: AWS region name.
644+
account_id: AWS account ID of the scanning account.
645+
646+
Returns:
647+
Dict with has_destinations, destinations,
648+
has_external_destination, and external_destinations.
649+
"""
650+
client = self.get_client("lambda", region)
651+
try:
652+
response = client.get_function_event_invoke_config(
653+
FunctionName=function_name
654+
)
655+
except ClientError as e:
656+
error_code = e.response.get("Error", {}).get(
657+
"Code", "Unknown"
658+
)
659+
if error_code == "ResourceNotFoundException":
660+
return {
661+
"has_destinations": False,
662+
"destinations": [],
663+
"has_external_destination": False,
664+
"external_destinations": [],
665+
}
666+
return self.handle_client_error(
667+
e,
668+
{
669+
"has_destinations": False,
670+
"destinations": [],
671+
"has_external_destination": False,
672+
"external_destinations": [],
673+
},
674+
)
675+
676+
dest_config = response.get("DestinationConfig", {})
677+
targets: List[str] = []
678+
for key in ("OnSuccess", "OnFailure"):
679+
arn = dest_config.get(key, {}).get("Destination")
680+
if arn:
681+
targets.append(arn)
682+
683+
external: List[str] = []
684+
for arn in targets:
685+
# arn:aws:<service>:<region>:<account-id>:<resource>
686+
parts = arn.split(":")
687+
if len(parts) >= 5:
688+
target_account = parts[4]
689+
if target_account and target_account != account_id:
690+
external.append(arn)
691+
692+
return {
693+
"has_destinations": len(targets) > 0,
694+
"destinations": targets,
695+
"has_external_destination": len(external) > 0,
696+
"external_destinations": external,
697+
}
698+
699+
def check_aliases(
700+
self, function_name: str, region: str
701+
) -> Dict:
702+
"""B.7: Check for alias traffic shadowing.
703+
704+
A weighted alias (RoutingConfig.AdditionalVersionWeights) sends a
705+
fraction of invocations to a second version. An attacker who can
706+
publish a version and call UpdateAlias uses this to quietly serve
707+
backdoored code to a slice of traffic while the primary version still
708+
looks clean, a stealthy persistence technique. Aliases with a routing
709+
config are surfaced for review.
710+
711+
Args:
712+
function_name: Lambda function name or ARN.
713+
region: AWS region name.
714+
715+
Returns:
716+
Dict with alias_count, shadowed_aliases, and
717+
has_shadowed_alias.
718+
"""
719+
client = self.get_client("lambda", region)
720+
try:
721+
response = client.list_aliases(
722+
FunctionName=function_name
723+
)
724+
except ClientError as e:
725+
return self.handle_client_error(
726+
e,
727+
{
728+
"alias_count": 0,
729+
"shadowed_aliases": [],
730+
"has_shadowed_alias": False,
731+
},
732+
)
733+
734+
aliases = response.get("Aliases", [])
735+
shadowed: List[Dict] = []
736+
for alias in aliases:
737+
weights = alias.get("RoutingConfig", {}).get(
738+
"AdditionalVersionWeights", {}
739+
)
740+
if weights:
741+
shadowed.append({
742+
"name": alias.get("Name"),
743+
"primary_version": alias.get("FunctionVersion"),
744+
"additional_versions": weights,
745+
})
746+
747+
return {
748+
"alias_count": len(aliases),
749+
"shadowed_aliases": shadowed,
750+
"has_shadowed_alias": len(shadowed) > 0,
751+
}

lambda_security_scanner/scanner.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def scan_function(
167167
progress=None,
168168
task=None,
169169
) -> Dict[str, Any]:
170-
"""Run all 19 checks for a single function."""
170+
"""Run all 21 checks for a single function."""
171171
func_name = func_config.get(
172172
"FunctionName", "unknown"
173173
)
@@ -218,7 +218,7 @@ def scan_function(
218218
)
219219
)
220220

221-
# B. Access Control (B.1-B.5)
221+
# B. Access Control (B.1-B.7)
222222
checks["resource_policy"] = (
223223
self.access_checker.check_resource_policy(
224224
func_name, self.region
@@ -244,6 +244,16 @@ def scan_function(
244244
role_arn, all_role_arns
245245
)
246246
)
247+
checks["destinations"] = (
248+
self.access_checker.check_destinations(
249+
func_name, self.region, self.account_id
250+
)
251+
)
252+
checks["aliases"] = (
253+
self.access_checker.check_aliases(
254+
func_name, self.region
255+
)
256+
)
247257

248258
# C. Network Security (C.1-C.3)
249259
checks["vpc_config"] = (
@@ -524,6 +534,31 @@ def add(severity, issue_type, desc, rec):
524534
"Create unique roles per function",
525535
)
526536

537+
# B.6 External-account async destination
538+
dest = checks.get("destinations", {})
539+
if dest.get("has_external_destination"):
540+
add(
541+
"CRITICAL", "external_account_destination",
542+
"Async-invoke destination points to an external "
543+
"account: "
544+
f"{dest.get('external_destinations', [])}",
545+
"Remove the cross-account destination and restrict "
546+
"lambda:PutFunctionEventInvokeConfig; allow-list any "
547+
"intended account",
548+
)
549+
550+
# B.7 Alias traffic shadowing
551+
alias = checks.get("aliases", {})
552+
if alias.get("has_shadowed_alias"):
553+
add(
554+
"MEDIUM", "alias_traffic_shadowing",
555+
"Alias splits traffic to additional versions "
556+
"(possible shadow version): "
557+
f"{alias.get('shadowed_aliases', [])}",
558+
"Verify the additional versions are intended and "
559+
"restrict lambda:UpdateAlias / lambda:PublishVersion",
560+
)
561+
527562
# C.1 VPC
528563
if not checks.get("vpc_config", {}).get("in_vpc"):
529564
add(

lambda_security_scanner/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ def get(check_name, key, default=False):
104104
if get("shared_role", "is_shared"):
105105
score -= 10
106106

107+
# B.6: Async-invoke destination to an external account (CRITICAL)
108+
if get("destinations", "has_external_destination"):
109+
score -= 20
110+
111+
# B.7: Alias splits traffic to a shadow version (MEDIUM)
112+
if get("aliases", "has_shadowed_alias"):
113+
score -= 5
114+
107115
# A.6: Tracing not enabled (observability hygiene, LOW)
108116
if not get("tracing", "enabled"):
109117
score -= 2

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "lambda-security-scanner"
7-
version = "1.0.1"
7+
version = "1.0.2"
88
description = "A comprehensive AWS Lambda security scanner with multi-framework compliance mapping"
99
readme = "README.md"
1010
requires-python = ">=3.10"

tests/test_access_control.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,101 @@ def test_shared_role(self):
454454
)
455455
self.assertTrue(result["is_shared"])
456456
self.assertEqual(result["shared_count"], 2)
457+
458+
459+
class TestCheckDestinations(TestCase):
460+
"""B.6 - Async-invoke destination external-account detection."""
461+
462+
def setUp(self):
463+
self.mock_client = Mock()
464+
mock_session = Mock()
465+
mock_session.client.return_value = self.mock_client
466+
self.checker = AccessControlChecker(lambda: mock_session)
467+
468+
def test_no_event_invoke_config(self):
469+
self.mock_client.get_function_event_invoke_config.side_effect = (
470+
_resource_not_found("GetFunctionEventInvokeConfig")
471+
)
472+
result = self.checker.check_destinations(
473+
"fn", "us-east-1", "111111111111"
474+
)
475+
self.assertFalse(result["has_destinations"])
476+
self.assertFalse(result["has_external_destination"])
477+
478+
def test_external_account_destination(self):
479+
self.mock_client.get_function_event_invoke_config.return_value = {
480+
"DestinationConfig": {
481+
"OnSuccess": {
482+
"Destination":
483+
"arn:aws:sqs:us-east-1:999999999999:attacker-q"
484+
},
485+
"OnFailure": {
486+
"Destination":
487+
"arn:aws:sqs:us-east-1:111111111111:dlq"
488+
},
489+
}
490+
}
491+
result = self.checker.check_destinations(
492+
"fn", "us-east-1", "111111111111"
493+
)
494+
self.assertTrue(result["has_destinations"])
495+
self.assertTrue(result["has_external_destination"])
496+
self.assertEqual(len(result["external_destinations"]), 1)
497+
self.assertIn(
498+
"999999999999", result["external_destinations"][0]
499+
)
500+
501+
def test_same_account_destination_not_flagged(self):
502+
self.mock_client.get_function_event_invoke_config.return_value = {
503+
"DestinationConfig": {
504+
"OnSuccess": {
505+
"Destination":
506+
"arn:aws:sns:us-east-1:111111111111:ok"
507+
}
508+
}
509+
}
510+
result = self.checker.check_destinations(
511+
"fn", "us-east-1", "111111111111"
512+
)
513+
self.assertTrue(result["has_destinations"])
514+
self.assertFalse(result["has_external_destination"])
515+
516+
517+
class TestCheckAliases(TestCase):
518+
"""B.7 - Alias traffic-shadowing detection."""
519+
520+
def setUp(self):
521+
self.mock_client = Mock()
522+
mock_session = Mock()
523+
mock_session.client.return_value = self.mock_client
524+
self.checker = AccessControlChecker(lambda: mock_session)
525+
526+
def test_no_routing_config_not_flagged(self):
527+
self.mock_client.list_aliases.return_value = {
528+
"Aliases": [
529+
{"Name": "prod", "FunctionVersion": "7"}
530+
]
531+
}
532+
result = self.checker.check_aliases("fn", "us-east-1")
533+
self.assertEqual(result["alias_count"], 1)
534+
self.assertFalse(result["has_shadowed_alias"])
535+
536+
def test_weighted_alias_flagged(self):
537+
self.mock_client.list_aliases.return_value = {
538+
"Aliases": [{
539+
"Name": "prod",
540+
"FunctionVersion": "7",
541+
"RoutingConfig": {
542+
"AdditionalVersionWeights": {"8": 0.1}
543+
},
544+
}]
545+
}
546+
result = self.checker.check_aliases("fn", "us-east-1")
547+
self.assertTrue(result["has_shadowed_alias"])
548+
self.assertEqual(
549+
result["shadowed_aliases"][0]["primary_version"], "7"
550+
)
551+
self.assertIn(
552+
"8",
553+
result["shadowed_aliases"][0]["additional_versions"],
554+
)

0 commit comments

Comments
 (0)