mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
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:
co-authored by
coderabbitai[bot]
Daniel Barranquero
Claude Opus 5
parent
90712c9ad7
commit
6e71dee85d
@@ -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
|
||||
+41
@@ -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": ""
|
||||
}
|
||||
+164
@@ -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
|
||||
|
||||
+444
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user