feat(awslambda): add awslambda_layer_no_secrets_in_content check (#12233)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
SaiGanesh
2026-08-07 11:33:54 +02:00
committed by GitHub
co-authored by coderabbitai[bot] Daniel Barranquero Claude Opus 5
parent 90712c9ad7
commit 6e71dee85d
8 changed files with 891 additions and 7 deletions
@@ -37,6 +37,7 @@
"glue:SearchTables",
"glue:GetMLTransforms",
"lambda:GetFunction*",
"lambda:GetLayerVersion",
"logs:FilterLogEvents",
"lightsail:GetRelationalDatabases",
"macie2:GetMacieSession",
@@ -0,0 +1 @@
`awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "awslambda_layer_no_secrets_in_content",
"CheckTitle": "Lambda layer content contains no hardcoded secrets",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Sensitive Data Identifications/Passwords",
"Effects/Data Exposure"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsLambdaLayerVersion",
"ResourceGroup": "serverless",
"Description": "**Lambda layer content** is analyzed for **embedded secrets** across files in the layer's package, detecting patterns like API keys, passwords, tokens, and connection strings. Findings reference file names and line numbers where potential secrets appear.",
"Risk": "**Hardcoded secrets** undermine confidentiality and integrity: a secret baked into a layer is pulled into every function that uses it, and is not covered by a function-code-only scan. If exposed, attackers can reuse credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes. Rotation is harder, increasing dwell time and blast radius.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/lambda/latest/dg/chapter-layers.html",
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html"
],
"Remediation": {
"Code": {
"CLI": "aws secretsmanager create-secret --name <secret-name> --secret-string <value>\naws iam put-role-policy --role-name <function-execution-role> --policy-name allow-get-secret --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"secretsmanager:GetSecretValue\",\"Resource\":\"<secret-arn>\"}]}'\n# Remove the hardcoded value from the layer's code, then:\naws lambda publish-layer-version --layer-name <layer-name> --zip-file fileb://layer.zip",
"NativeIaC": "",
"Other": "1. In AWS Secrets Manager, click Store a new secret and create a secret for the value you hardcoded. Note the secret name/ARN.\n2. In IAM > Roles, open the execution role of every function that uses this layer and add an inline policy allowing secretsmanager:GetSecretValue on that secret only.\n3. Remove the hardcoded value from the layer's code and repackage it, retrieving the secret at runtime using the AWS SDK (GetSecretValue) with the secret name/ARN.\n4. Publish a new layer version and update dependent functions to use it.",
"Terraform": ""
},
"Recommendation": {
"Text": "Use **AWS Secrets Manager** (or Parameter Store) to store secrets and retrieve at runtime; never put them in layer code or packaged dependencies.\n- Apply **least privilege** IAM\n- Enable **rotation**\n- Prevent secret logging; encrypt\n- Add CI/CD secret scanning",
"Url": "https://hub.prowler.com/check/awslambda_layer_no_secrets_in_content"
}
},
"Categories": [
"secrets"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,164 @@
import fnmatch
import os
import tempfile
from collections import defaultdict
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.logger import logger
from prowler.lib.utils.utils import (
SecretsScanError,
annotate_verified_secrets,
detect_secrets_scan_batch,
)
from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client
class awslambda_layer_no_secrets_in_content(Check):
"""Check if Lambda layer content contains hardcoded secrets.
Scans every file inside each Lambda layer version's package with the
secret scanner.
- PASS: No secrets are detected in the layer content.
- FAIL: At least one potential secret is detected in the layer content.
- MANUAL: The layer content could not be fetched or scanned.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the Lambda layer secrets scan.
Returns:
list[Check_Report_AWS]: One report per Lambda layer version used by
the audited functions, or an empty list when there are no layers.
"""
findings = []
if not awslambda_client.layers:
return findings
secrets_ignore_patterns = awslambda_client.audit_config.get(
"secrets_ignore_patterns", []
)
# Glob patterns of file names inside the layer package to skip
# when scanning for secrets (e.g. "*.deps.json" for .NET layers).
secrets_ignore_files = (
awslambda_client.audit_config.get("secrets_ignore_files", []) or []
)
validate = awslambda_client.audit_config.get("secrets_validate", False)
# Scan files of every layer version's package in batched
# Kingfisher invocations instead of one subprocess per file per layer.
# Each package is extracted one at a time and its files are
# read (byte-faithfully via latin-1) before the extraction is released,
# so only a single package is on disk at a time. Findings are keyed by
# (layer index, package-relative file name) so they can be grouped
# back per layer.
layers_with_code = []
def code_payloads():
for layer, layer_code in awslambda_client._get_layers_code():
if not layer_code:
continue
with tempfile.TemporaryDirectory() as tmp_dir_name:
try:
layer_code.code_zip.extractall(tmp_dir_name)
except Exception as error:
# A corrupt or truncated package must not abort the
# scan of the remaining layers: keep this layer out of
# layers_with_code so it is reported as MANUAL below.
logger.error(
f"{layer.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
index = len(layers_with_code)
layers_with_code.append(layer)
for root, _, files in os.walk(tmp_dir_name):
for file_name in files:
file_path = os.path.join(root, file_name)
relative_file_path = os.path.relpath(
file_path, tmp_dir_name
)
if any(
fnmatch.fnmatch(relative_file_path, pattern)
for pattern in secrets_ignore_files
):
continue
try:
with open(file_path, "rb") as code_file:
content = code_file.read().decode("latin-1")
except Exception:
continue
yield (index, relative_file_path), content
scan_error = None
try:
batch_results = detect_secrets_scan_batch(
code_payloads(),
excluded_secrets=secrets_ignore_patterns,
validate=validate,
)
except SecretsScanError as error:
batch_results = {}
scan_error = error
if scan_error:
# The scan failed before any layer's code could be cleared. Report
# MANUAL for every layer rather than risk a false PASS.
for layer in awslambda_client.layers.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=layer)
report.status = "MANUAL"
report.status_extended = (
f"Could not scan Lambda layer {layer.name} (version "
f"{layer.version}) content for secrets: {scan_error}; "
"manual review is required."
)
findings.append(report)
return findings
findings_by_layer = defaultdict(dict)
for (index, file_name), file_findings in batch_results.items():
findings_by_layer[index][file_name] = file_findings
for index, layer in enumerate(layers_with_code):
report = Check_Report_AWS(metadata=self.metadata(), resource=layer)
report.status = "PASS"
report.status_extended = (
f"No secrets found in Lambda layer {layer.name} "
f"(version {layer.version}) content."
)
files_with_secrets = findings_by_layer.get(index)
if files_with_secrets:
all_secrets = []
secrets_findings = []
for file_name, file_findings in files_with_secrets.items():
all_secrets.extend(file_findings)
secrets_string = ", ".join(
f"{secret['type']} on line {secret['line_number']}"
for secret in file_findings
)
secrets_findings.append(f"{file_name}: {secrets_string}")
final_output_string = "; ".join(secrets_findings)
report.status = "FAIL"
report.status_extended = f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} found in Lambda layer {layer.name} (version {layer.version}) content -> {final_output_string}."
annotate_verified_secrets(report, all_secrets)
findings.append(report)
# Layers whose content could not be fetched (network error, missing
# permissions, etc.) never reach layers_with_code above, so report
# them as MANUAL rather than silently omitting them from the scan.
fetched_arns = {layer.arn for layer in layers_with_code}
for layer in awslambda_client.layers.values():
if layer.arn in fetched_arns:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=layer)
report.status = "MANUAL"
report.status_extended = (
f"Could not retrieve content of Lambda layer {layer.name} "
f"(version {layer.version}) to scan for secrets; manual "
"review is required."
)
findings.append(report)
return findings
@@ -17,6 +17,11 @@ from prowler.lib.resource_limit import (
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
from prowler.providers.aws.lib.service.service import AWSService
# Presigned code/layer download URLs are short-lived S3 URLs, not AWS API
# calls, so a hung request here would otherwise block a worker thread
# indefinitely instead of failing like the surrounding boto3 calls do.
CODE_DOWNLOAD_TIMEOUT_SECONDS = 30
class Lambda(AWSService):
def __init__(self, provider):
@@ -25,6 +30,7 @@ class Lambda(AWSService):
# Functions are listed first, then trimmed to the subset selected for
# analysis before expensive per-function detail is hydrated.
self.functions = {}
self.layers = {}
self.security_groups_in_use = set()
self.regions_with_functions = set()
self.function_limit = get_resource_scan_limit(
@@ -32,6 +38,7 @@ class Lambda(AWSService):
)
self.__threading_call__(self._list_functions)
self._select_functions_for_analysis()
self._collect_layers()
self._list_tags_for_resource()
self.__threading_call__(self._get_policy)
self.__threading_call__(self._get_function_url_config)
@@ -106,6 +113,11 @@ class Lambda(AWSService):
)
}
def _collect_layers(self):
for function in self.functions.values():
for layer in function.layers:
self.layers.setdefault(layer.arn, layer)
def _list_event_source_mappings(self, regional_client):
logger.info("Lambda - Listing Event Source Mappings...")
try:
@@ -193,6 +205,15 @@ class Lambda(AWSService):
f"{function.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _download_code(self, code_location_uri):
raw_code_zip = requests.get(
code_location_uri, timeout=CODE_DOWNLOAD_TIMEOUT_SECONDS
).content
return LambdaCode(
location=code_location_uri,
code_zip=zipfile.ZipFile(io.BytesIO(raw_code_zip)),
)
def _fetch_function_code(self, function_name, function_region):
try:
regional_client = self.regional_clients[function_region]
@@ -200,18 +221,52 @@ class Lambda(AWSService):
FunctionName=function_name
)
if "Location" in function_information["Code"]:
code_location_uri = function_information["Code"]["Location"]
raw_code_zip = requests.get(code_location_uri).content
return LambdaCode(
location=code_location_uri,
code_zip=zipfile.ZipFile(io.BytesIO(raw_code_zip)),
)
return self._download_code(function_information["Code"]["Location"])
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
raise
def _get_layers_code(self):
logger.info("Lambda - Getting Layer Code...")
# Use a thread pool to handle the queueing and execution of the
# _fetch_layer_code tasks, up to max_workers tasks concurrently.
layers_to_fetch = {
self.thread_pool.submit(
self._fetch_layer_code, layer.arn, layer.region
): layer
for layer in self.layers.values()
}
for fetched_layer_code in as_completed(layers_to_fetch):
layer = layers_to_fetch[fetched_layer_code]
try:
layer_code = fetched_layer_code.result()
if layer_code:
yield layer, layer_code
except Exception as error:
logger.error(
f"{layer.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _fetch_layer_code(self, layer_arn, layer_region):
try:
regional_client = self.regional_clients[layer_region]
# Fetch by the full layer-version ARN: layers attached to a
# function may be owned by another account (e.g. vendor or
# AWS-provided layers), where a bare layer name would resolve
# against the audited account instead.
layer_version = regional_client.get_layer_version_by_arn(Arn=layer_arn)
if "Location" in (layer_version.get("Content") or {}):
return self._download_code(layer_version["Content"]["Location"])
return None
except Exception as error:
logger.error(
f"{layer_region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
raise
def _get_policy(self, regional_client):
logger.info("Lambda - Getting Policy...")
try:
@@ -308,6 +363,28 @@ class Layer(BaseModel):
parts = self.arn.split(":")
return parts[4] if len(parts) >= 5 else ""
@property
def region(self) -> str:
"""Extract the region from the layer ARN.
A layer can only be attached to a function in the same region, so
this is always one of the regions already being audited.
"""
parts = self.arn.split(":")
return parts[3] if len(parts) >= 4 else ""
@property
def name(self) -> str:
"""Extract the layer name from the ARN."""
parts = self.arn.split(":")
return parts[6] if len(parts) >= 7 else self.arn
@property
def version(self) -> str:
"""Extract the layer version from the ARN."""
parts = self.arn.split(":")
return parts[7] if len(parts) >= 8 else ""
class DeadLetterConfig(BaseModel):
target_arn: str
@@ -0,0 +1,444 @@
import os
import zipfile
from unittest import mock
from prowler.providers.aws.services.awslambda.awslambda_service import (
LambdaCode,
Layer,
)
from tests.providers.aws.services.awslambda.awslambda_service_test import (
create_zip_file,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
LAMBDA_LAYER_NAME = "test-layer"
LAMBDA_LAYER_ARN = (
f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:layer:"
f"{LAMBDA_LAYER_NAME}:1"
)
LAMBDA_UNFETCHED_LAYER_NAME = "unfetched-layer"
LAMBDA_UNFETCHED_LAYER_ARN = (
f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:layer:"
f"{LAMBDA_UNFETCHED_LAYER_NAME}:2"
)
LAMBDA_CORRUPT_LAYER_NAME = "corrupt-layer"
LAMBDA_CORRUPT_LAYER_ARN = (
f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:layer:"
f"{LAMBDA_CORRUPT_LAYER_NAME}:3"
)
LAMBDA_LAYER_CONTENT_WITH_SECRETS = """
db_password = "Tr0ub4dor3xKq9vLmZ"
"""
LAMBDA_LAYER_CONTENT_WITHOUT_SECRETS = """
def helper():
return True
"""
def create_lambda_layer() -> Layer:
return Layer(arn=LAMBDA_LAYER_ARN)
def get_lambda_layer_code(content):
return LambdaCode(
location="",
code_zip=zipfile.ZipFile(create_zip_file(content)),
)
def get_lambda_layer_code_from_files(files: dict) -> LambdaCode:
# The check only calls code_zip.extractall(dir); mock it to drop the
# given files into the temporary directory the check creates, so no
# real archive needs to be built.
code_zip = mock.MagicMock()
def _extractall(path):
for name, content in files.items():
os.makedirs(os.path.dirname(f"{path}/{name}"), exist_ok=True)
with open(f"{path}/{name}", "w") as fd:
fd.write(content)
code_zip.extractall.side_effect = _extractall
return LambdaCode(location="", code_zip=code_zip)
def mock_get_layers_code_with_nested_vendor_secret():
yield create_lambda_layer(), get_lambda_layer_code_from_files(
{
"python/lib.py": LAMBDA_LAYER_CONTENT_WITHOUT_SECRETS,
"python/vendor/package.js": 'const dbPassword = "test-vendor-password";',
}
)
def mock_get_layers_code_with_secrets():
yield create_lambda_layer(), get_lambda_layer_code(
LAMBDA_LAYER_CONTENT_WITH_SECRETS
)
def mock_get_layers_code_without_secrets():
yield create_lambda_layer(), get_lambda_layer_code(
LAMBDA_LAYER_CONTENT_WITHOUT_SECRETS
)
def get_lambda_layer_code_with_unreadable_file() -> LambdaCode:
# A dangling symlink is walked as a file but cannot be opened, which is
# how an unreadable member of the layer package behaves for the check.
code_zip = mock.MagicMock()
def _extractall(path):
with open(f"{path}/readable.py", "w") as fd:
fd.write(LAMBDA_LAYER_CONTENT_WITHOUT_SECRETS)
os.symlink(f"{path}/does-not-exist", f"{path}/dangling.py")
code_zip.extractall.side_effect = _extractall
return LambdaCode(location="", code_zip=code_zip)
def mock_get_layers_code_with_unreadable_file():
yield create_lambda_layer(), get_lambda_layer_code_with_unreadable_file()
def mock_get_layers_code_empty_code():
yield create_lambda_layer(), None
def get_lambda_layer_code_with_corrupt_archive() -> LambdaCode:
code_zip = mock.MagicMock()
code_zip.extractall.side_effect = zipfile.BadZipFile("truncated archive")
return LambdaCode(location="", code_zip=code_zip)
def mock_get_layers_code_one_corrupt_one_clean():
yield (
Layer(arn=LAMBDA_CORRUPT_LAYER_ARN),
get_lambda_layer_code_with_corrupt_archive(),
)
yield create_lambda_layer(), get_lambda_layer_code(
LAMBDA_LAYER_CONTENT_WITHOUT_SECRETS
)
def mock_get_layers_code_partial_fetch_failure():
# Only the fetchable layer is yielded; the client's failing fetch for
# the other layer already logged and skipped it (see _get_layers_code).
yield create_lambda_layer(), get_lambda_layer_code(
LAMBDA_LAYER_CONTENT_WITHOUT_SECRETS
)
class Test_awslambda_layer_no_secrets_in_content:
def test_no_layers(self):
lambda_client = mock.MagicMock
lambda_client.layers = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 0
def test_layer_content_with_secrets(self):
lambda_client = mock.MagicMock
lambda_client.layers = {LAMBDA_LAYER_ARN: create_lambda_layer()}
lambda_client._get_layers_code = mock_get_layers_code_with_secrets
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 1
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == LAMBDA_LAYER_NAME
assert result[0].resource_arn == LAMBDA_LAYER_ARN
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in Lambda layer {LAMBDA_LAYER_NAME} (version 1) content -> lambda_function.py: Generic Password on line 2."
)
assert result[0].resource_tags == []
def test_layer_content_without_secrets(self):
lambda_client = mock.MagicMock
lambda_client.layers = {LAMBDA_LAYER_ARN: create_lambda_layer()}
lambda_client._get_layers_code = mock_get_layers_code_without_secrets
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 1
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == LAMBDA_LAYER_NAME
assert result[0].resource_arn == LAMBDA_LAYER_ARN
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"No secrets found in Lambda layer {LAMBDA_LAYER_NAME} (version 1) content."
)
assert result[0].resource_tags == []
def test_layer_content_nested_vendor_secret_not_ignored(self):
lambda_client = mock.MagicMock
lambda_client.layers = {LAMBDA_LAYER_ARN: create_lambda_layer()}
lambda_client._get_layers_code = mock_get_layers_code_with_nested_vendor_secret
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "python/vendor/package.js" in result[0].status_extended
def test_layer_content_nested_vendor_secret_ignored_by_file_pattern(self):
lambda_client = mock.MagicMock
lambda_client.layers = {LAMBDA_LAYER_ARN: create_lambda_layer()}
lambda_client._get_layers_code = mock_get_layers_code_with_nested_vendor_secret
lambda_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_ignore_files": ["python/vendor/*.js"],
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_layer_content_unreadable_file_is_skipped(self):
lambda_client = mock.MagicMock
lambda_client.layers = {LAMBDA_LAYER_ARN: create_lambda_layer()}
lambda_client._get_layers_code = mock_get_layers_code_with_unreadable_file
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
# The unreadable file is skipped, the rest of the package is still
# scanned, so the layer is reported instead of being dropped.
assert len(result) == 1
assert result[0].resource_arn == LAMBDA_LAYER_ARN
assert result[0].status == "PASS"
def test_corrupt_layer_archive_reports_manual_and_scan_continues(self):
lambda_client = mock.MagicMock
lambda_client.layers = {
LAMBDA_CORRUPT_LAYER_ARN: Layer(arn=LAMBDA_CORRUPT_LAYER_ARN),
LAMBDA_LAYER_ARN: create_lambda_layer(),
}
lambda_client._get_layers_code = mock_get_layers_code_one_corrupt_one_clean
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
# The corrupt archive must not abort the scan of the clean layer.
assert len(result) == 2
by_arn = {r.resource_arn: r for r in result}
assert by_arn[LAMBDA_LAYER_ARN].status == "PASS"
corrupt = by_arn[LAMBDA_CORRUPT_LAYER_ARN]
assert corrupt.status == "MANUAL"
assert "manual review is required" in corrupt.status_extended
def test_layer_with_empty_code_reports_manual(self):
lambda_client = mock.MagicMock
lambda_client.layers = {LAMBDA_LAYER_ARN: create_lambda_layer()}
lambda_client._get_layers_code = mock_get_layers_code_empty_code
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not retrieve content" in result[0].status_extended
def test_partial_fetch_failure_reports_manual_for_unfetched_layer(self):
lambda_client = mock.MagicMock
lambda_client.layers = {
LAMBDA_LAYER_ARN: create_lambda_layer(),
LAMBDA_UNFETCHED_LAYER_ARN: Layer(arn=LAMBDA_UNFETCHED_LAYER_ARN),
}
lambda_client._get_layers_code = mock_get_layers_code_partial_fetch_failure
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 2
by_arn = {r.resource_arn: r for r in result}
assert by_arn[LAMBDA_LAYER_ARN].status == "PASS"
unfetched = by_arn[LAMBDA_UNFETCHED_LAYER_ARN]
assert unfetched.status == "MANUAL"
assert unfetched.resource_id == LAMBDA_UNFETCHED_LAYER_NAME
assert unfetched.region == AWS_REGION_US_EAST_1
assert "manual review is required" in unfetched.status_extended
def test_scan_failure_reports_manual_not_pass(self):
from prowler.lib.utils.utils import SecretsScanError
lambda_client = mock.MagicMock
lambda_client.layers = {LAMBDA_LAYER_ARN: create_lambda_layer()}
lambda_client._get_layers_code = mock_get_layers_code_with_secrets
lambda_client.audit_config = {"secrets_ignore_patterns": []}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.awslambda_client",
new=lambda_client,
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher exited with code 1"),
),
):
from prowler.providers.aws.services.awslambda.awslambda_layer_no_secrets_in_content.awslambda_layer_no_secrets_in_content import (
awslambda_layer_no_secrets_in_content,
)
check = awslambda_layer_no_secrets_in_content()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not scan" in result[0].status_extended
@@ -15,6 +15,7 @@ from prowler.providers.aws.services.awslambda.awslambda_service import (
AuthType,
Function,
Lambda,
Layer,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
@@ -44,7 +45,7 @@ def create_zip_file(code: str = "") -> io.BytesIO:
return zip_output
def mock_request_get(_):
def mock_request_get(_, **kwargs):
"""Mock requests.get() to get the Lambda Code in Zip Format"""
mock_resp = mock.MagicMock
mock_resp.status_code = 200
@@ -680,3 +681,158 @@ class Test_Lambda_Service:
assert len(list(awslambda._get_function_code())) == 1
assert len(fetched) == 1
def test_layer_properties_parsed_from_arn(self):
layer = Layer(
arn=f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:layer:my-layer:3"
)
assert layer.region == AWS_REGION_US_EAST_1
assert layer.name == "my-layer"
assert layer.version == "3"
assert layer.account_id == AWS_ACCOUNT_NUMBER
def test_collect_layers_deduplicates_across_functions(self):
layer_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:layer:shared-layer:1"
awslambda = Lambda.__new__(Lambda)
awslambda.layers = {}
awslambda.functions = {
"function-1": Function(
name="function-1",
arn="function-1",
security_groups=[],
region=AWS_REGION_US_EAST_1,
layers=[Layer(arn=layer_arn)],
),
"function-2": Function(
name="function-2",
arn="function-2",
security_groups=[],
region=AWS_REGION_US_EAST_1,
layers=[Layer(arn=layer_arn)],
),
}
awslambda._collect_layers()
assert len(awslambda.layers) == 1
assert awslambda.layers[layer_arn].arn == layer_arn
@mock_aws
def test_get_layers_code_fetches_each_layer_once(self):
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
iam_role = iam_client.create_role(
RoleName="test-role",
AssumeRolePolicyDocument="{}",
)["Role"]["Arn"]
lambda_client = client("lambda", region_name=AWS_REGION_US_EAST_1)
layer_code = "shared_secret = 'hunter2'"
layer_arn = lambda_client.publish_layer_version(
LayerName="shared-layer",
Content={"ZipFile": create_zip_file(layer_code).read()},
CompatibleRuntimes=["python3.9"],
)["LayerVersionArn"]
for name in ("function-1", "function-2"):
lambda_client.create_function(
FunctionName=name,
Runtime="python3.9",
Role=iam_role,
Handler="lambda_function.lambda_handler",
Code={"ZipFile": create_zip_file().read()},
PackageType="ZIP",
Layers=[layer_arn],
)
with mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_service.requests.get",
new=mock_request_get,
):
awslambda = Lambda(
set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
)
assert len(awslambda.layers) == 1
assert awslambda.layers[layer_arn].name == "shared-layer"
assert awslambda.layers[layer_arn].version == "1"
# moto's get_layer_version_by_arn omits Content.Location, so
# delegate to get_layer_version, which moto implements fully.
regional_client = awslambda.regional_clients[AWS_REGION_US_EAST_1]
def get_layer_version_by_arn(Arn):
assert Arn == layer_arn
return regional_client.get_layer_version(
LayerName="shared-layer", VersionNumber=1
)
with mock.patch.object(
regional_client,
"get_layer_version_by_arn",
side_effect=get_layer_version_by_arn,
):
layers_fetched = list(awslambda._get_layers_code())
assert len(layers_fetched) == 1
fetched_layer, fetched_code = layers_fetched[0]
assert fetched_layer.arn == layer_arn
assert fetched_code
@mock_aws
def test_get_layers_code_skips_layer_that_cannot_be_fetched(self):
awslambda = Lambda(
set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
)
layer_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:layer:missing-layer:1"
awslambda.layers = {layer_arn: Layer(arn=layer_arn)}
# moto answers GetLayerVersionByArn with an empty stub instead of
# raising for an unknown layer, so the failure is forced here.
regional_client = mock.MagicMock()
regional_client.get_layer_version_by_arn.side_effect = Exception(
"ResourceNotFoundException"
)
awslambda.regional_clients[AWS_REGION_US_EAST_1] = regional_client
# The lookup raises inside _fetch_layer_code; _get_layers_code must
# log it and yield nothing rather than propagating to the check.
assert list(awslambda._get_layers_code()) == []
@mock_aws
def test_fetch_layer_code_returns_none_without_location(self):
awslambda = Lambda(
set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
)
awslambda.regional_clients[AWS_REGION_US_EAST_1] = mock.MagicMock()
awslambda.regional_clients[
AWS_REGION_US_EAST_1
].get_layer_version_by_arn.return_value = {"Content": {}}
layer_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:layer:my-layer:1"
assert awslambda._fetch_layer_code(layer_arn, AWS_REGION_US_EAST_1) is None
# An absent Content must be handled like an empty one rather than
# raising on the membership test.
awslambda.regional_clients[
AWS_REGION_US_EAST_1
].get_layer_version_by_arn.return_value = {"Content": None}
assert awslambda._fetch_layer_code(layer_arn, AWS_REGION_US_EAST_1) is None
@mock_aws
def test_fetch_layer_code_uses_full_layer_version_arn(self):
awslambda = Lambda(
set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
)
regional_client = mock.MagicMock()
regional_client.get_layer_version_by_arn.return_value = {"Content": {}}
awslambda.regional_clients[AWS_REGION_US_EAST_1] = regional_client
# A layer owned by another account must be fetched by its full
# layer-version ARN, never by the bare layer name.
foreign_layer_arn = (
f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:999999999999:"
"layer:vendor-extension:5"
)
awslambda._fetch_layer_code(foreign_layer_arn, AWS_REGION_US_EAST_1)
regional_client.get_layer_version_by_arn.assert_called_once_with(
Arn=foreign_layer_arn
)