diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 11e694d9ba..c8af5b38ce 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Replaced the `detect-secrets` library with [Kingfisher](https://github.com/mongodb/kingfisher) as the engine for the secret-scanning checks; scans run fully offline by default and obvious placeholder values are no longer reported as findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) - Removed the `detect_secrets_plugins` configuration option, which is no longer used by the new secret-scanning engine [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- `awslambda_function_no_secrets_in_code` now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets [(#11222)](https://github.com/prowler-cloud/prowler/pull/11222) ### 🐞 Fixed diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 5a796d698c..22e05c0883 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -449,6 +449,20 @@ aws: # Patterns to ignore in the secrets checks secrets_ignore_patterns: [] + # aws.awslambda_function_no_secrets_in_code + # Glob patterns of file names inside the Lambda deployment package to skip + # when scanning for secrets. Useful to suppress known false positives such + # as .NET dependency manifests. + # Example: + # secrets_ignore_files: + # - "*.deps.json" + # WARNING: use at your own risk. Any file whose name matches one of these + # patterns is fully excluded from secret scanning, so a real secret placed + # in such a file will NOT be detected. Keep patterns as narrow and specific + # as possible; this is not recommended unless you have confirmed the matched + # files only ever contain false positives. + secrets_ignore_files: [] + # Validate discovered secrets by checking whether they are live against the # provider APIs. WARNING: this makes outbound network calls that authenticate # with the discovered secret itself; the credential is exercised against the diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py index a0dbe91932..4a31458f7d 100644 --- a/prowler/config/schema/aws.py +++ b/prowler/config/schema/aws.py @@ -426,6 +426,7 @@ class AWSProviderConfig(ProviderConfigBase): # --- Secrets --------------------------------------------------------- secrets_ignore_patterns: Optional[list[str]] = None + secrets_ignore_files: Optional[list[str]] = None secrets_validate: Optional[bool] = Field( default=None, description=( diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py index 66ecf1e88f..d4f3010903 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py @@ -1,3 +1,4 @@ +import fnmatch import os import tempfile from collections import defaultdict @@ -20,14 +21,20 @@ class awslambda_function_no_secrets_in_code(Check): secrets_ignore_patterns = awslambda_client.audit_config.get( "secrets_ignore_patterns", [] ) + # Glob patterns of file names inside the deployment package to skip + # when scanning for secrets (e.g. "*.deps.json" for .NET Lambdas). + secrets_ignore_files = ( + awslambda_client.audit_config.get("secrets_ignore_files", []) or [] + ) validate = awslambda_client.audit_config.get("secrets_validate", False) - # Scan the top-level files of every function's package in batched + # Scan files of every function's package in batched # Kingfisher invocations instead of one subprocess per file per function. - # Each package is extracted one at a time and its top-level files are + # 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 - # (function index, file name) so they can be grouped back per function. + # (function index, package-relative file name) so they can be grouped + # back per function. functions_with_code = [] def code_payloads(): @@ -38,15 +45,23 @@ class awslambda_function_no_secrets_in_code(Check): functions_with_code.append(function) with tempfile.TemporaryDirectory() as tmp_dir_name: function_code.code_zip.extractall(tmp_dir_name) - for file_name in next(os.walk(tmp_dir_name))[2]: - try: - with open( - os.path.join(tmp_dir_name, file_name), "rb" - ) as code_file: - content = code_file.read().decode("latin-1") - except Exception: - continue - yield (index, file_name), content + 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: diff --git a/tests/config/schema/aws_schema_test.py b/tests/config/schema/aws_schema_test.py index 787cb4c03e..d37a2e0bf6 100644 --- a/tests/config/schema/aws_schema_test.py +++ b/tests/config/schema/aws_schema_test.py @@ -176,6 +176,29 @@ class Test_AWS_Enums: assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {} +class Test_AWS_Secrets_Ignore_Files: + def test_valid_file_patterns_round_trip(self): + files = ["*.deps.json", "vendor/*.js"] + assert _validate({"secrets_ignore_files": files}) == { + "secrets_ignore_files": files + } + + def test_empty_list_is_valid(self): + assert _validate({"secrets_ignore_files": []}) == {"secrets_ignore_files": []} + + def test_exposed_in_scan_config_schema(self): + aws_properties = SCAN_CONFIG_SCHEMA["properties"]["aws"]["properties"] + + assert aws_properties["secrets_ignore_files"] == { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "default": None, + "title": "Secrets Ignore Files", + } + + class Test_AWS_Booleans: @pytest.mark.parametrize( "key", diff --git a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py index e6399eb3ce..a68724da7f 100644 --- a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py +++ b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py @@ -1,3 +1,4 @@ +import os import zipfile from unittest import mock @@ -53,6 +54,57 @@ def get_lambda_code_with_secrets(code): ) +LAMBDA_DEPS_JSON_WITH_SECRET = """ +{ + "runtimeTarget": { "name": ".NETCoreApp,Version=v8.0" }, + "libraries": { + "AWSSDK.SecretsManager/3.7.0": { + "type": "package", + "password": "test-deps-json-password" + } + } +} +""" + +LAMBDA_VENDOR_JS_WITH_SECRET = """ +const dbPassword = "test-vendor-password"; +""" + + +def get_lambda_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_function_code_with_deps_json_secret(): + yield create_lambda_function(), get_lambda_code_from_files( + { + "lambda_function.py": LAMBDA_FUNCTION_CODE_WITHOUT_SECRETS, + "myapp.deps.json": LAMBDA_DEPS_JSON_WITH_SECRET, + } + ) + + +def mock_get_function_code_with_nested_vendor_secret(): + yield create_lambda_function(), get_lambda_code_from_files( + { + "lambda_function.py": LAMBDA_FUNCTION_CODE_WITHOUT_SECRETS, + "vendor/package.js": LAMBDA_VENDOR_JS_WITH_SECRET, + } + ) + + def mock_get_function_codewith_secrets(): yield create_lambda_function(), get_lambda_code_with_secrets( LAMBDA_FUNCTION_CODE_WITH_SECRETS @@ -202,6 +254,133 @@ class Test_awslambda_function_no_secrets_in_code: ) assert result[0].resource_tags == [] + def test_function_code_deps_json_secret_not_ignored(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = mock_get_function_code_with_deps_json_secret + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_ignore_files": None, + } + + 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_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "myapp.deps.json" in result[0].status_extended + + def test_function_code_nested_vendor_secret_not_ignored(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = ( + mock_get_function_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_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "vendor/package.js" in result[0].status_extended + + def test_function_code_nested_vendor_secret_ignored_by_file_pattern(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = ( + mock_get_function_code_with_nested_vendor_secret + ) + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_ignore_files": ["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_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_function_code_deps_json_secret_ignored_by_file_pattern(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = mock_get_function_code_with_deps_json_secret + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_ignore_files": ["*.deps.json"], + } + + 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_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == LAMBDA_FUNCTION_NAME + assert result[0].resource_arn == LAMBDA_FUNCTION_ARN + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"No secrets found in Lambda function {LAMBDA_FUNCTION_NAME} code." + ) + assert result[0].resource_tags == [] + def test_scan_failure_reports_manual_not_pass(self): from prowler.lib.utils.utils import SecretsScanError @@ -209,6 +388,7 @@ class Test_awslambda_function_no_secrets_in_code: lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} lambda_client._get_function_code = mock_get_function_codewith_secrets lambda_client.audit_config = {"secrets_ignore_patterns": []} + with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider",