feat(sdk): replace detect-secrets library with kingfisher (#11694)

This commit is contained in:
Daniel Barranquero
2026-06-30 15:36:23 +02:00
committed by GitHub
parent ed1fec8866
commit 5dac8a0a53
60 changed files with 2969 additions and 881 deletions
-45
View File
@@ -129,47 +129,6 @@ class Test_AWS_Enums:
assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {}
class Test_AWS_Detect_Secrets_Plugins:
def test_plugin_without_limit(self):
out = _validate({"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]})
assert out == {"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]}
def test_plugin_with_limit(self):
out = _validate(
{
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": 6.0}
]
}
)
assert out == {
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": 6.0}
]
}
def test_plugin_missing_name_drops_whole_field(self):
# ``name`` is required by the upstream library.
out = _validate({"detect_secrets_plugins": [{"limit": 6.0}]})
assert out == {}
def test_extra_plugin_kwargs_pass_through(self):
# Plugins can have arbitrary extra params (extra="allow" on the
# nested model). They must round-trip.
out = _validate(
{
"detect_secrets_plugins": [
{"name": "Custom", "my_param": "abc", "other": 42}
]
}
)
assert out == {
"detect_secrets_plugins": [
{"name": "Custom", "my_param": "abc", "other": 42}
]
}
class Test_AWS_Booleans:
@pytest.mark.parametrize(
"key",
@@ -214,9 +173,5 @@ class Test_AWS_Full_Default_Config_Round_Trips:
"threat_detection_enumeration_threshold": 0.3,
"threat_detection_llm_jacking_threshold": 0.4,
"ec2_high_risk_ports": [25, 110, 8088],
"detect_secrets_plugins": [
{"name": "AWSKeyDetector"},
{"name": "Base64HighEntropyString", "limit": 6.0},
],
}
assert _validate(raw) == raw
-32
View File
@@ -342,38 +342,6 @@ class TestTrustedIpsValidator:
assert _has_error_for(errors, "aws.trusted_ips")
class TestDetectSecretsEntropyBound:
"""`detect_secrets_plugins[].limit` is Shannon entropy: 0..10."""
@pytest.mark.parametrize("value", [0.0, 3.5, 4.5, 8.0, 10.0])
def test_valid(self, value):
assert (
validate_scan_config(
{
"aws": {
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": value}
]
}
}
)
== []
)
@pytest.mark.parametrize("value", [-0.1, 10.01, 50])
def test_invalid(self, value):
errors = validate_scan_config(
{
"aws": {
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": value}
]
}
}
)
assert _has_error_for(errors, "aws.detect_secrets_plugins")
class TestAdapterRobustness:
"""Top-level adapter behaviour the Prowler App backend depends on."""
@@ -193,21 +193,8 @@ class Test_OpenStack_Schema:
raw = {
"image_sharing_threshold": 5,
"secrets_ignore_patterns": ["AKIA[0-9A-Z]{16}"],
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": 4.5}
],
}
assert _validate("openstack", raw) == raw
def test_zero_threshold_dropped(self):
assert _validate("openstack", {"image_sharing_threshold": 0}) == {}
def test_invalid_plugin_entropy_dropped(self):
# Reuses the AWS _DetectSecretsPlugin entropy bound (0..10).
assert (
_validate(
"openstack",
{"detect_secrets_plugins": [{"name": "X", "limit": 50}]},
)
== {}
)
+187 -63
View File
@@ -1,4 +1,5 @@
import os
import subprocess
import tempfile
from datetime import datetime
from time import mktime
@@ -7,7 +8,8 @@ import pytest
from mock import patch
from prowler.lib.utils.utils import (
detect_secrets_scan,
SecretsScanError,
detect_secrets_scan_batch,
file_exists,
get_file_permissions,
hash_sha512,
@@ -20,6 +22,95 @@ from prowler.lib.utils.utils import (
)
def _fake_kingfisher_run(output_content=None, returncode=0, stderr=""):
"""Build a ``subprocess.run`` replacement that mimics a Kingfisher call.
When ``output_content`` is given it is written to the ``--output`` path from
the command (so the reader sees realistic file content); the call returns a
CompletedProcess with the requested ``returncode``/``stderr``.
"""
def _run(command, *_args, **_kwargs):
if output_content is not None:
output_path = command[command.index("--output") + 1]
with open(output_path, "w") as output_file:
output_file.write(output_content)
return subprocess.CompletedProcess(
command, returncode, stdout="", stderr=stderr
)
return _run
def _fake_kingfisher_run_with_findings(findings):
"""Build a ``subprocess.run`` replacement that emits crafted findings.
Each entry in ``findings`` is a ``(payload_index, line)`` pair: the finding
is mapped back to the temp file named ``str(payload_index)`` (the basename
``_scan_batch_chunk`` writes per payload) and given the requested ``line``
value (omitted entirely when ``line`` is the sentinel ``_OMIT``). Returns a
success exit code so only the finding shape is under test.
"""
def _run(command, *_args, **_kwargs):
output_path = command[command.index("--output") + 1]
entries = []
for payload_index, line in findings:
finding = {"path": str(payload_index), "snippet": "secret"}
if line is not _OMIT:
finding["line"] = line
entries.append({"finding": finding, "rule": {"name": "Generic Secret"}})
import json as _json
with open(output_path, "w") as output_file:
output_file.write(_json.dumps({"findings": entries}))
return subprocess.CompletedProcess(command, 200, stdout="", stderr="")
return _run
_OMIT = object()
class Test_detect_secrets_scan_batch_invalid_line:
"""Kingfisher's ``line`` is consumed as a trusted 1-based index by checks
(e.g. CloudWatch ``events[line_number - 1]``). A malformed line must fail
closed as SecretsScanError, never return a finding with a bad index."""
@pytest.mark.parametrize(
"line",
[_OMIT, None, "2", 0, -1, 5, True],
ids=["missing", "none", "string", "zero", "negative", "out_of_range", "bool"],
)
def test_invalid_line_raises(self, line):
# Payload "data" is a single line, so any line other than 1 is invalid.
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=_fake_kingfisher_run_with_findings([(0, line)]),
):
with pytest.raises(SecretsScanError) as exc:
detect_secrets_scan_batch({"a": "data"})
assert "invalid line number" in str(exc.value)
def test_valid_line_is_returned(self):
# A valid in-range line must still pass through to the caller.
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=_fake_kingfisher_run_with_findings([(0, 1)]),
):
results = detect_secrets_scan_batch({"a": "data"})
assert results["a"][0]["line_number"] == 1
def test_one_invalid_line_aborts_the_whole_scan(self):
# Even mixed with a valid finding, a single invalid line fails closed.
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=_fake_kingfisher_run_with_findings([(0, 1), (1, 0)]),
):
with pytest.raises(SecretsScanError):
detect_secrets_scan_batch({"a": "data", "b": "data"})
class Test_utils_open_file:
def test_open_read_file(self):
temp_data_file = tempfile.NamedTemporaryFile(delete=False)
@@ -108,75 +199,108 @@ class Test_utils_validate_ip_address:
assert not validate_ip_address("Not an IP")
class Test_detect_secrets_scan:
def test_detect_secrets_scan_data(self):
data = "password=password"
secrets_detected = detect_secrets_scan(data=data, excluded_secrets=[])
assert type(secrets_detected) is list
assert len(secrets_detected) == 1
assert "filename" in secrets_detected[0]
assert "hashed_secret" in secrets_detected[0]
assert "is_verified" in secrets_detected[0]
assert secrets_detected[0]["line_number"] == 1
assert secrets_detected[0]["type"] == "Secret Keyword"
def test_detect_secrets_scan_no_secrets_data(self):
data = ""
assert detect_secrets_scan(data=data) is None
def test_detect_secrets_scan_file_with_secrets(self):
temp_data_file = tempfile.NamedTemporaryFile(delete=False)
temp_data_file.write(b"password=password")
temp_data_file.seek(0)
secrets_detected = detect_secrets_scan(
file=temp_data_file.name, excluded_secrets=[]
class Test_detect_secrets_scan_batch:
def test_batch_returns_findings_per_key(self):
results = detect_secrets_scan_batch(
{
"a": 'password = "Tr0ub4dor3xKq9vLmZ"',
"b": "just a normal config = value",
}
)
assert type(secrets_detected) is list
assert len(secrets_detected) == 1
assert "filename" in secrets_detected[0]
assert "hashed_secret" in secrets_detected[0]
assert "is_verified" in secrets_detected[0]
assert secrets_detected[0]["line_number"] == 1
assert secrets_detected[0]["type"] == "Secret Keyword"
os.remove(temp_data_file.name)
assert "a" in results
assert results["a"][0]["type"] == "Generic Password"
# keys without findings are omitted
assert "b" not in results
def test_detect_secrets_scan_file_no_secrets(self):
temp_data_file = tempfile.NamedTemporaryFile(delete=False)
temp_data_file.write(b"no secrets")
temp_data_file.seek(0)
assert detect_secrets_scan(file=temp_data_file.name) is None
os.remove(temp_data_file.name)
def test_batch_no_dedup_reports_identical_secret_in_each_key(self):
# The same secret in two payloads must be reported for both (matches
# scanning each payload individually).
secret = "token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
results = detect_secrets_scan_batch({"a": secret, "b": secret})
assert "a" in results
assert "b" in results
def test_detect_secrets_using_regex(self):
data = "MYSQL_ALLOW_EMPTY_PASSWORD=password"
secrets_detected = detect_secrets_scan(
data=data, excluded_secrets=[".*password"]
def test_batch_excluded_secrets_filters(self):
results = detect_secrets_scan_batch(
{"a": 'DB_ALLOW_EMPTY_PASSWORD = "Tr0ub4dor3xKq9vLmZ"'},
excluded_secrets=[".*ALLOW_EMPTY_PASSWORD.*"],
)
assert secrets_detected is None
assert results == {}
def test_detect_secrets_using_regex_file(self):
temp_data_file = tempfile.NamedTemporaryFile(delete=False)
temp_data_file.write(b"MYSQL_ALLOW_EMPTY_PASSWORD=password")
temp_data_file.seek(0)
secrets_detected = detect_secrets_scan(
file=temp_data_file.name, excluded_secrets=[".*password"]
)
assert secrets_detected is None
os.remove(temp_data_file.name)
def test_batch_chunking_maps_all_keys(self):
payloads = {f"k{i}": f'password = "S3cr3tV4lu3xy{i}z"' for i in range(5)}
results = detect_secrets_scan_batch(payloads, chunk_size=2)
assert sorted(results.keys()) == ["k0", "k1", "k2", "k3", "k4"]
def test_detect_secrets_secrets_using_regex(self):
data = "MYSQL_ALLOW_EMPTY_PASSWORD=password, MYSQL_PASSWORD=password"
# Update the regex to exclude only the exact key "MYSQL_ALLOW_EMPTY_PASSWORD"
secrets_detected = detect_secrets_scan(
data=data, excluded_secrets=["^MYSQL_ALLOW_EMPTY_PASSWORD$"]
def test_batch_empty_payloads(self):
assert detect_secrets_scan_batch({}) == {}
def test_batch_accepts_iterable_of_pairs(self):
results = detect_secrets_scan_batch(
iter([("x", 'password = "Tr0ub4dor3xKq9vLmZ"')])
)
assert type(secrets_detected) is list
assert len(secrets_detected) == 1
assert "filename" in secrets_detected[0]
assert "hashed_secret" in secrets_detected[0]
assert "is_verified" in secrets_detected[0]
assert secrets_detected[0]["line_number"] == 1
assert secrets_detected[0]["type"] == "Secret Keyword"
assert "x" in results
class Test_detect_secrets_scan_batch_failures:
"""A scanner failure must surface as SecretsScanError, never as empty
results (which a caller would read as 'no secrets found')."""
def test_non_zero_exit_code_raises(self):
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=_fake_kingfisher_run(returncode=1, stderr="boom"),
):
with pytest.raises(SecretsScanError) as exc:
detect_secrets_scan_batch({"a": "data"})
assert "exited with code 1" in str(exc.value)
assert "boom" in str(exc.value)
def test_timeout_raises(self):
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="kingfisher", timeout=300),
):
with pytest.raises(SecretsScanError) as exc:
detect_secrets_scan_batch({"a": "data"})
assert "timed out" in str(exc.value)
def test_malformed_json_output_raises(self):
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=_fake_kingfisher_run(
output_content="{not valid json", returncode=0
),
):
with pytest.raises(SecretsScanError):
detect_secrets_scan_batch({"a": "data"})
def test_missing_binary_raises(self):
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=FileNotFoundError("kingfisher binary not found"),
):
with pytest.raises(SecretsScanError):
detect_secrets_scan_batch({"a": "data"})
def test_empty_output_is_not_a_failure(self):
# Empty output means the scan ran and found nothing; it must NOT raise.
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=_fake_kingfisher_run(output_content="", returncode=0),
):
assert detect_secrets_scan_batch({"a": "data"}) == {}
def test_failure_in_any_chunk_aborts_the_whole_scan(self):
# A failure in any chunk must abort the whole scan, not silently return
# partial results from the chunks that happened to succeed first.
payloads = {f"k{i}": "data" for i in range(4)}
with patch(
"prowler.lib.utils.utils.subprocess.run",
side_effect=_fake_kingfisher_run(returncode=2, stderr="boom"),
):
with pytest.raises(SecretsScanError):
detect_secrets_scan_batch(payloads, chunk_size=2)
class Test_hash_sha512:
@@ -104,7 +104,7 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration:
InstanceType="t1.micro",
KeyName="the_keys",
SecurityGroups=["default", "default2"],
UserData="DB_PASSWORD=foobar123",
UserData='DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"',
)
launch_configuration_arn = autoscaling_client.describe_launch_configurations(
LaunchConfigurationNames=[launch_configuration_name]
@@ -341,7 +341,9 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration:
check = autoscaling_find_secrets_ec2_launch_configuration()
result = check.execute()
assert len(result) == 0
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not decode User Data" in result[0].status_extended
@mock_aws
def test_one_autoscaling_file_invalid_gzip_error(self):
@@ -381,4 +383,6 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration:
check = autoscaling_find_secrets_ec2_launch_configuration()
result = check.execute()
assert len(result) == 0
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not decode User Data" in result[0].status_extended
@@ -1,4 +1,4 @@
DB_PASSWORD=foobar123
DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"
DB_USER=foo
API_KEY=12345abcd
SERVICE_PASSWORD=bbaabb45
API_KEY=s3rv1c3Acc0untS3cr3tV4lu3x9
SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4"
@@ -19,7 +19,7 @@ LAMBDA_FUNCTION_RUNTIME = "nodejs4.3"
LAMBDA_FUNCTION_ARN = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{LAMBDA_FUNCTION_NAME}"
LAMBDA_FUNCTION_CODE_WITH_SECRETS = """
def lambda_handler(event, context):
db_password = "test-password"
db_password = "Tr0ub4dor3xKq9vLmZ"
print("custom log event")
return event
"""
@@ -126,7 +126,7 @@ class Test_awslambda_function_no_secrets_in_code:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in Lambda function {LAMBDA_FUNCTION_NAME} code -> lambda_function.py: Secret Keyword on line 3."
== f"Potential secret found in Lambda function {LAMBDA_FUNCTION_NAME} code -> lambda_function.py: Generic Password on line 3."
)
assert result[0].resource_tags == []
@@ -201,3 +201,35 @@ class Test_awslambda_function_no_secrets_in_code:
== 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
lambda_client = mock.MagicMock
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",
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,
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher exited with code 1"),
),
):
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 == "MANUAL"
assert "Could not scan" in result[0].status_extended
@@ -97,7 +97,7 @@ class Test_awslambda_function_no_secrets_in_variables:
arn=function_arn,
region=AWS_REGION_US_EAST_1,
runtime=function_runtime,
environment={"db_password": "test-password"},
environment={"db_password": "Tr0ub4dor3xKq9vLmZ"},
)
}
@@ -126,7 +126,7 @@ class Test_awslambda_function_no_secrets_in_variables:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in Lambda function {function_name} variables -> Secret Keyword in variable db_password."
== f"Potential secret found in Lambda function {function_name} variables -> Generic Password in variable db_password."
)
assert result[0].resource_tags == []
@@ -145,7 +145,69 @@ class Test_awslambda_function_no_secrets_in_variables:
arn=function_arn,
region=AWS_REGION_US_EAST_1,
runtime=function_runtime,
environment={"db_password": "srv://admin:pass@db"},
environment={
"db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
},
)
}
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_variables.awslambda_function_no_secrets_in_variables.awslambda_client",
new=lambda_client,
),
):
# Test Check
from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import (
awslambda_function_no_secrets_in_variables,
)
check = awslambda_function_no_secrets_in_variables()
result = check.execute()
assert len(result) == 1
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == function_name
assert result[0].resource_arn == function_arn
assert result[0].status == "FAIL"
# Kingfisher reports both the generic keyword rule and the JWT rule
# for the same value; their order is not guaranteed, so assert on
# presence rather than a fixed concatenation order.
assert result[0].status_extended.startswith(
f"Potential secret found in Lambda function {function_name} variables -> "
)
assert (
"Generic Password in variable db_password" in result[0].status_extended
)
assert (
"JSON Web Token (base64url-encoded) in variable db_password"
in result[0].status_extended
)
assert result[0].resource_tags == []
def test_function_secrets_in_variables_telegram_token(self):
lambda_client = mock.MagicMock
function_name = "test-lambda"
function_runtime = "nodejs4.3"
function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}"
lambda_client.audit_config = {"secrets_ignore_patterns": []}
lambda_client.functions = {
"function_name": Function(
name=function_name,
security_groups=[],
arn=function_arn,
region=AWS_REGION_US_EAST_1,
runtime=function_runtime,
environment={
# The Telegram bot-token rule is no longer enabled in
# Kingfisher's built-in ruleset, so a detectable JWT
# is used to keep this token-in-variable case meaningful.
"TELEGRAM_BOT_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
},
)
}
@@ -174,16 +236,22 @@ class Test_awslambda_function_no_secrets_in_variables:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in Lambda function {function_name} variables -> Secret Keyword in variable db_password, Basic Auth Credentials in variable db_password."
== f"Potential secret found in Lambda function {function_name} variables -> JSON Web Token (base64url-encoded) in variable TELEGRAM_BOT_TOKEN."
)
assert result[0].resource_tags == []
def test_function_secrets_in_variables_telegram_token(self):
def test_function_with_verified_secret(self):
from prowler.lib.check.models import Severity
lambda_client = mock.MagicMock
function_name = "test-lambda"
function_runtime = "nodejs4.3"
function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}"
lambda_client.audit_config = {"secrets_ignore_patterns": []}
lambda_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": True,
}
lambda_client.functions = {
"function_name": Function(
name=function_name,
@@ -191,19 +259,35 @@ class Test_awslambda_function_no_secrets_in_variables:
arn=function_arn,
region=AWS_REGION_US_EAST_1,
runtime=function_runtime,
environment={"TELEGRAM_BOT_TOKEN": "telegram-token"},
environment={"db_password": "test-value"},
)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
return_value=set_mocked_aws_provider(
audit_config={"secrets_validate": True}
),
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.awslambda_client",
new=lambda_client,
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.detect_secrets_scan_batch",
return_value={
0: [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 2,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
# Test Check
from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import (
@@ -213,16 +297,13 @@ class Test_awslambda_function_no_secrets_in_variables:
check = awslambda_function_no_secrets_in_variables()
result = check.execute()
# The check must forward secrets_validate from the config to the scan.
assert mock_scan.call_args.kwargs.get("validate") is True
assert len(result) == 1
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert result[0].resource_id == function_name
assert result[0].resource_arn == function_arn
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"No secrets found in Lambda function {function_name} variables."
)
assert result[0].resource_tags == []
def test_function_no_secrets_in_variables(self):
lambda_client = mock.MagicMock
@@ -270,3 +351,48 @@ class Test_awslambda_function_no_secrets_in_variables:
== f"No secrets found in Lambda function {function_name} variables."
)
assert result[0].resource_tags == []
def test_scan_failure_reports_manual_not_pass(self):
# A scanner failure must not be treated as "no secrets found".
from prowler.lib.utils.utils import SecretsScanError
lambda_client = mock.MagicMock
function_name = "test-lambda"
function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}"
lambda_client.audit_config = {"secrets_ignore_patterns": []}
lambda_client.functions = {
"function_name": Function(
name=function_name,
security_groups=[],
arn=function_arn,
region=AWS_REGION_US_EAST_1,
runtime="nodejs4.3",
environment={"db_password": "test-value"},
)
}
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_variables.awslambda_function_no_secrets_in_variables.awslambda_client",
new=lambda_client,
),
mock.patch(
"prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher exited with code 1"),
),
):
from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import (
awslambda_function_no_secrets_in_variables,
)
check = awslambda_function_no_secrets_in_variables()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not scan" in result[0].status_extended
assert "manual review is required" in result[0].status_extended
@@ -38,7 +38,10 @@ class Test_cloudformation_stack_outputs_find_secrets:
Stack(
arn="arn:aws:cloudformation:eu-west-1:123456789012:stack/Test-Stack/796c8d26-b390-41d7-a23c-0702c4e78b60",
name=stack_name,
outputs=["DB_PASSWORD:foobar123", "ENV:DEV"],
outputs=[
"DB_KEY:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"ENV:DEV",
],
region=AWS_REGION,
)
]
@@ -66,7 +69,7 @@ class Test_cloudformation_stack_outputs_find_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in CloudFormation Stack {stack_name} Outputs -> Secret Keyword in Output 1."
== f"Potential secret found in CloudFormation Stack {stack_name} Outputs -> JSON Web Token (base64url-encoded) in Output 1."
)
assert result[0].resource_id == "Test-Stack"
assert (
@@ -132,7 +132,7 @@ class Test_cloudwatch_log_group_no_secrets_in_logs:
logEvents=[
{
"timestamp": timestamp,
"message": "password = password123",
"message": 'password = "Tr0ub4dor3xKq9vLmZ"',
}
],
)
@@ -174,7 +174,7 @@ class Test_cloudwatch_log_group_no_secrets_in_logs:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - Secret Keyword on line 1."
== f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - Generic Password on line 1."
)
assert result[0].resource_id == "test"
assert (
@@ -222,3 +222,323 @@ class Test_cloudwatch_log_group_no_secrets_in_logs:
result = check.execute()
assert len(result) == 0
@mock_aws
def test_cloudwatch_multiline_event_all_secrets_ignored_is_pass(self):
# Regression: a multiline event whose secrets are all dropped by the
# rescan (e.g. filtered by secrets_ignore_patterns) must NOT produce a
# FAIL with no actual secret evidence.
logs_client = client("logs", region_name=AWS_REGION_US_EAST_1)
logs_client.create_log_group(logGroupName="test", tags={"test": "test"})
logs_client.create_log_stream(logGroupName="test", logStreamName="test stream")
logs_client.put_log_events(
logGroupName="test",
logStreamName="test stream",
logEvents=[
{
"timestamp": timestamp,
# Valid JSON so the rescan expands it to multiple lines.
"message": '{"api_key": "AKIAIOSFODNN7EXAMPLE", "note": "x"}',
}
],
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
from prowler.providers.common.models import Audit_Metadata
aws_provider.audit_metadata = Audit_Metadata(
services_scanned=0,
expected_checks=["cloudwatch_log_group_no_secrets_in_logs"],
completed_checks=0,
audit_progress=0,
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client",
new=Logs(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch",
side_effect=[
# Phase 1: stream flagged on its single (multiline) event.
{
("test", "test stream"): [
{
"type": "AWS Access Key",
"line_number": 1,
"filename": "data",
"hashed_secret": "x",
"is_verified": False,
}
]
},
# Phase 3: rescan drops everything (all secrets ignored).
{},
],
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import (
cloudwatch_log_group_no_secrets_in_logs,
)
check = cloudwatch_log_group_no_secrets_in_logs()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == "No secrets found in test log group."
@mock_aws
def test_cloudwatch_scan_failure_reports_manual(self):
# A scanner failure on the stream scan must surface as MANUAL, not PASS.
from prowler.lib.utils.utils import SecretsScanError
logs_client = client("logs", region_name=AWS_REGION_US_EAST_1)
logs_client.create_log_group(logGroupName="test", tags={"test": "test"})
logs_client.create_log_stream(logGroupName="test", logStreamName="test stream")
logs_client.put_log_events(
logGroupName="test",
logStreamName="test stream",
logEvents=[{"timestamp": timestamp, "message": "some log line"}],
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
from prowler.providers.common.models import Audit_Metadata
aws_provider.audit_metadata = Audit_Metadata(
services_scanned=0,
expected_checks=["cloudwatch_log_group_no_secrets_in_logs"],
completed_checks=0,
audit_progress=0,
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client",
new=Logs(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher exited with code 1"),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import (
cloudwatch_log_group_no_secrets_in_logs,
)
check = cloudwatch_log_group_no_secrets_in_logs()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not scan" in result[0].status_extended
@mock_aws
def test_two_multiline_events_same_timestamp_do_not_collide(self):
# Regression: a CloudWatch stream can hold several events sharing one
# millisecond timestamp. The multiline rescan must be keyed per event
# (not only per timestamp), otherwise the later event's payload
# overwrites the earlier one and secret evidence is lost.
log_group_arn = (
f"arn:aws:logs:{AWS_REGION_US_EAST_1}:123456789012:log-group:test:*"
)
logs_client = client("logs", region_name=AWS_REGION_US_EAST_1)
logs_client.create_log_group(logGroupName="test", tags={"test": "test"})
logs_client.create_log_stream(logGroupName="test", logStreamName="test stream")
# Two distinct multiline (valid JSON) events at the same timestamp.
logs_client.put_log_events(
logGroupName="test",
logStreamName="test stream",
logEvents=[
{
"timestamp": timestamp,
"message": '{"api_key": "AKIAIOSFODNN7EXAMPLE", "note": "a"}',
},
{
"timestamp": timestamp,
"message": '{"secret": "AKIAI44QH8DHBEXAMPLE", "note": "b"}',
},
],
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
from prowler.providers.common.models import Audit_Metadata
aws_provider.audit_metadata = Audit_Metadata(
services_scanned=0,
expected_checks=["cloudwatch_log_group_no_secrets_in_logs"],
completed_checks=0,
audit_progress=0,
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client",
new=Logs(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch",
side_effect=[
# Phase 1: both events flagged (one secret on each line).
{
(log_group_arn, "test stream"): [
{
"type": "AWS Access Key",
"line_number": 1,
"filename": "data",
"hashed_secret": "a",
"is_verified": False,
},
{
"type": "AWS Access Key",
"line_number": 2,
"filename": "data",
"hashed_secret": "b",
"is_verified": False,
},
]
},
# Phase 3: each event is rescanned under its own key. If the
# keys collided, only one of these would survive.
{
(
log_group_arn,
"test stream",
dttimestamp,
0,
): [
{
"type": "AWS Access Key",
"line_number": 2,
"filename": "data",
"hashed_secret": "a",
"is_verified": False,
}
],
(
log_group_arn,
"test stream",
dttimestamp,
1,
): [
{
"type": "AWS Access Key",
"line_number": 2,
"filename": "data",
"hashed_secret": "b",
"is_verified": False,
}
],
},
],
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import (
cloudwatch_log_group_no_secrets_in_logs,
)
check = cloudwatch_log_group_no_secrets_in_logs()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
# Both events' secrets must be reported, not just the last one.
assert (
result[0].status_extended
== f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - AWS Access Key on line 2."
)
@mock_aws
def test_same_group_and_stream_names_in_two_regions_do_not_collide(self):
# Regression: log group and stream names are not unique across regions,
# so the per-stream key must be region-aware (ARN-based). Otherwise the
# secret found in one region would be reused for the same-named group in
# another region, producing a false FAIL.
group_name = "shared-name"
stream_name = "shared stream"
us_client = client("logs", region_name=AWS_REGION_US_EAST_1)
us_client.create_log_group(logGroupName=group_name)
us_client.create_log_stream(logGroupName=group_name, logStreamName=stream_name)
us_client.put_log_events(
logGroupName=group_name,
logStreamName=stream_name,
logEvents=[
{
"timestamp": timestamp,
"message": 'password = "Tr0ub4dor3xKq9vLmZ"',
}
],
)
eu_client = client("logs", region_name=AWS_REGION_EU_WEST_1)
eu_client.create_log_group(logGroupName=group_name)
eu_client.create_log_stream(logGroupName=group_name, logStreamName=stream_name)
eu_client.put_log_events(
logGroupName=group_name,
logStreamName=stream_name,
logEvents=[{"timestamp": timestamp, "message": "just a normal log line"}],
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
from prowler.providers.common.models import Audit_Metadata
aws_provider.audit_metadata = Audit_Metadata(
services_scanned=0,
expected_checks=["cloudwatch_log_group_no_secrets_in_logs"],
completed_checks=0,
audit_progress=0,
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client",
new=Logs(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import (
cloudwatch_log_group_no_secrets_in_logs,
)
check = cloudwatch_log_group_no_secrets_in_logs()
result = check.execute()
assert len(result) == 2
by_region = {report.region: report for report in result}
# Only the region with the real secret must FAIL.
assert by_region[AWS_REGION_US_EAST_1].status == "FAIL"
assert by_region[AWS_REGION_EU_WEST_1].status == "PASS"
@@ -1,6 +1,10 @@
from unittest import mock
from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
class Test_codebuild_project_no_secrets_in_variables:
@@ -202,7 +206,11 @@ class Test_codebuild_project_no_secrets_in_variables:
environment_variables=[
{
"name": "AWS_ACCESS_KEY_ID",
"value": "AKIAIOSFODNN7EXAMPLE",
# Realistic fake secret that Kingfisher detects. The classic
# "AKIAIOSFODNN7EXAMPLE" placeholder is suppressed by
# Kingfisher and its AWS Access Key rule is not enabled, so a
# detectable provider secret is used instead.
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"type": "PLAINTEXT",
}
],
@@ -231,15 +239,100 @@ class Test_codebuild_project_no_secrets_in_variables:
assert len(result) == 1
assert result[0].status == "FAIL"
# The JWT paired with a "KEY" variable name yields both a
# JWT and a Generic API Key finding; order is non-deterministic.
assert result[0].status_extended.startswith(
"CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:"
)
assert (
result[0].status_extended
== "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_ACCESS_KEY_ID."
"JSON Web Token (base64url-encoded) in variable AWS_ACCESS_KEY_ID"
in result[0].status_extended
)
assert (
"Generic API Key in variable AWS_ACCESS_KEY_ID"
in result[0].status_extended
)
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "SensitiveProject"
assert result[0].resource_arn == project_arn
assert result[0].resource_tags == []
def test_project_with_verified_secret(self):
from prowler.lib.check.models import Severity
codebuild_client = mock.MagicMock()
from prowler.providers.aws.services.codebuild.codebuild_service import Project
project_arn = f"arn:aws:codebuild:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:project/SensitiveProject"
codebuild_client.projects = {
project_arn: Project(
name="SensitiveProject",
arn=project_arn,
region=AWS_REGION_US_EAST_1,
last_invoked_time=None,
buildspec=None,
environment_variables=[
{
"name": "EXAMPLE_VAR",
"value": "ExampleValue",
"type": "PLAINTEXT",
}
],
tags=[],
)
}
codebuild_client.audit_config = {
"excluded_sensitive_environment_variables": [],
"secrets_validate": True,
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(
audit_config={"secrets_validate": True}
),
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_service.Codebuild",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.codebuild_client",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.detect_secrets_scan_batch",
return_value={
(0, 0): [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 1,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
from prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables import (
codebuild_project_no_secrets_in_variables,
)
check = codebuild_project_no_secrets_in_variables()
result = check.execute()
# The check must forward secrets_validate from the config to the scan.
assert mock_scan.call_args.kwargs.get("validate") is True
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert result[0].resource_id == "SensitiveProject"
def test_project_with_sensitive_plaintext_credentials_exluded(self):
codebuild_client = mock.MagicMock
@@ -373,12 +466,12 @@ class Test_codebuild_project_no_secrets_in_variables:
environment_variables=[
{
"name": "AWS_DUMB_ACCESS_KEY",
"value": "AKIAIOSFODNN7EXAMPLE",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"type": "PLAINTEXT",
},
{
"name": "AWS_ACCESS_KEY_ID",
"value": "AKIAIOSFODNN7EXAMPLE",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"type": "PLAINTEXT",
},
],
@@ -409,10 +502,21 @@ class Test_codebuild_project_no_secrets_in_variables:
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_ACCESS_KEY_ID."
# AWS_DUMB_ACCESS_KEY is excluded, so only AWS_ACCESS_KEY_ID is
# scanned; its JWT + "KEY" name yields both a JWT and a
# Generic API Key finding with non-deterministic order.
assert result[0].status_extended.startswith(
"CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:"
)
assert (
"JSON Web Token (base64url-encoded) in variable AWS_ACCESS_KEY_ID"
in result[0].status_extended
)
assert (
"Generic API Key in variable AWS_ACCESS_KEY_ID"
in result[0].status_extended
)
assert "AWS_DUMB_ACCESS_KEY" not in result[0].status_extended
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "SensitiveProject"
assert result[0].resource_arn == project_arn
@@ -434,12 +538,12 @@ class Test_codebuild_project_no_secrets_in_variables:
environment_variables=[
{
"name": "AWS_DUMB_ACCESS_KEY",
"value": "AKIAIOSFODNN7EXAMPLE",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"type": "PLAINTEXT",
},
{
"name": "AWS_ACCESS_KEY_ID",
"value": "AKIAIOSFODNN7EXAMPLE",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"type": "PLAINTEXT",
},
],
@@ -468,11 +572,80 @@ class Test_codebuild_project_no_secrets_in_variables:
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_DUMB_ACCESS_KEY, AWS Access Key in variable AWS_ACCESS_KEY_ID."
# Both variables hold a JWT and have "KEY" in their name, so
# each yields a JWT and a Generic API Key finding; order is
# non-deterministic.
assert result[0].status_extended.startswith(
"CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:"
)
for var_name in ("AWS_DUMB_ACCESS_KEY", "AWS_ACCESS_KEY_ID"):
assert (
f"JSON Web Token (base64url-encoded) in variable {var_name}"
in result[0].status_extended
)
assert (
f"Generic API Key in variable {var_name}"
in result[0].status_extended
)
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "SensitiveProject"
assert result[0].resource_arn == project_arn
assert result[0].resource_tags == []
def test_scan_failure_reports_manual(self):
from prowler.lib.utils.utils import SecretsScanError
codebuild_client = mock.MagicMock()
from prowler.providers.aws.services.codebuild.codebuild_service import Project
project_arn = f"arn:aws:codebuild:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:project/SensitiveProject"
codebuild_client.projects = {
project_arn: Project(
name="SensitiveProject",
arn=project_arn,
region=AWS_REGION_US_EAST_1,
last_invoked_time=None,
buildspec=None,
environment_variables=[
{
"name": "EXAMPLE_VAR",
"value": "ExampleValue",
"type": "PLAINTEXT",
}
],
tags=[],
)
}
codebuild_client.audit_config = {
"excluded_sensitive_environment_variables": [],
"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.codebuild.codebuild_service.Codebuild",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.codebuild_client",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher exited with code 1"),
),
):
from prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables import (
codebuild_project_no_secrets_in_variables,
)
check = codebuild_project_no_secrets_in_variables()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not scan" in result[0].status_extended
@@ -100,7 +100,7 @@ class Test_ec2_instance_secrets_user_data:
ImageId=EXAMPLE_AMI_ID,
MinCount=1,
MaxCount=1,
UserData="DB_PASSWORD=foobar123",
UserData='DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"',
)[0]
from prowler.providers.aws.services.ec2.ec2_service import EC2
@@ -130,7 +130,7 @@ class Test_ec2_instance_secrets_user_data:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1."
== f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1."
)
assert result[0].resource_id == instance.id
assert (
@@ -233,7 +233,7 @@ class Test_ec2_instance_secrets_user_data:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4."
== f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4."
)
assert result[0].resource_id == instance.id
assert (
@@ -327,7 +327,7 @@ class Test_ec2_instance_secrets_user_data:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4."
== f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4."
)
assert result[0].resource_id == instance.id
assert (
@@ -337,6 +337,64 @@ class Test_ec2_instance_secrets_user_data:
assert result[0].resource_tags is None
assert result[0].region == AWS_REGION_US_EAST_1
@mock_aws
def test_one_ec2_with_verified_secret(self):
from prowler.lib.check.models import Severity
ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1)
instance = ec2.create_instances(
ImageId=EXAMPLE_AMI_ID,
MinCount=1,
MaxCount=1,
UserData='STRIPE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"',
)[0]
from prowler.providers.aws.services.ec2.ec2_service import EC2
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
audit_config={"secrets_validate": True},
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.ec2_client",
new=EC2(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.detect_secrets_scan_batch",
return_value={
0: [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 1,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
from prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data import (
ec2_instance_secrets_user_data,
)
check = ec2_instance_secrets_user_data()
result = check.execute()
# The check must forward secrets_validate from the config to the scan.
assert mock_scan.call_args.kwargs.get("validate") is True
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert result[0].resource_id == instance.id
@mock_aws
def test_one_secrets_with_unicode_error(self):
invalid_utf8_bytes = b"\xc0\xaf"
@@ -368,4 +426,50 @@ class Test_ec2_instance_secrets_user_data:
check = ec2_instance_secrets_user_data()
result = check.execute()
assert len(result) == 0
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not decode User Data" in result[0].status_extended
@mock_aws
def test_scan_failure_reports_manual(self):
from prowler.lib.utils.utils import SecretsScanError
ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1)
instance = ec2.create_instances(
ImageId=EXAMPLE_AMI_ID,
MinCount=1,
MaxCount=1,
UserData='password = "Tr0ub4dor3xKq9vLmZ"',
)[0]
from prowler.providers.aws.services.ec2.ec2_service import EC2
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.ec2_client",
new=EC2(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher exited with code 1"),
),
):
from prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data import (
ec2_instance_secrets_user_data,
)
check = ec2_instance_secrets_user_data()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not scan" in result[0].status_extended
assert result[0].resource_id == instance.id
@@ -1,4 +1,4 @@
DB_PASSWORD=foobar123
DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"
DB_USER=foo
API_KEY=12345abcd
SERVICE_PASSWORD=bbaabb45
STRIPE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4"
@@ -29,7 +29,9 @@ def mock_make_api_call(self, operation_name, kwarg):
"VersionNumber": 123,
"LaunchTemplateData": {
"UserData": b64encode(
"DB_PASSWORD=foobar123".encode(encoding_format_utf_8)
'DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"'.encode(
encoding_format_utf_8
)
).decode(encoding_format_utf_8),
"NetworkInterfaces": [{"AssociatePublicIpAddress": True}],
},
@@ -164,7 +166,7 @@ class Test_ec2_launch_template_no_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Potential secret found in User Data for EC2 Launch Template tester1 in template versions: Version 123: Secret Keyword on line 1."
== "Potential secret found in User Data for EC2 Launch Template tester1 in template versions: Version 123: Generic Password on line 1."
)
assert result[0].resource_id == "lt-1234567890"
assert result[0].region == AWS_REGION_US_EAST_1
@@ -212,7 +214,7 @@ class Test_ec2_launch_template_no_secrets:
)
ec2_client.launch_templates = [launch_template]
ec2_client.audit_config = {"detect_secrets_plugins": None}
ec2_client.audit_config = {}
with (
mock.patch(
@@ -236,7 +238,7 @@ class Test_ec2_launch_template_no_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4, Version 2: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4."
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4, Version 2: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4."
)
assert result[0].resource_id == launch_template_id
assert result[0].region == AWS_REGION_US_EAST_1
@@ -290,7 +292,7 @@ class Test_ec2_launch_template_no_secrets:
)
ec2_client.launch_templates = [launch_template]
ec2_client.audit_config = {"detect_secrets_plugins": None}
ec2_client.audit_config = {}
with (
mock.patch(
@@ -314,7 +316,7 @@ class Test_ec2_launch_template_no_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4."
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4."
)
assert result[0].resource_id == launch_template_id
assert result[0].region == AWS_REGION_US_EAST_1
@@ -358,7 +360,7 @@ class Test_ec2_launch_template_no_secrets:
)
ec2_client.launch_templates = [launch_template]
ec2_client.audit_config = {"detect_secrets_plugins": None}
ec2_client.audit_config = {}
with (
mock.patch(
@@ -382,7 +384,7 @@ class Test_ec2_launch_template_no_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4."
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4."
)
assert result[0].resource_id == launch_template_id
assert result[0].region == AWS_REGION_US_EAST_1
@@ -391,6 +393,81 @@ class Test_ec2_launch_template_no_secrets:
)
assert result[0].resource_tags == []
def test_one_launch_template_with_verified_secret(self):
from prowler.lib.check.models import Severity
ec2_client = mock.MagicMock()
launch_template_name = "tester"
launch_template_id = "lt-1234567890"
launch_template_arn = (
f"arn:aws:ec2:us-east-1:123456789012:launch-template/{launch_template_id}"
)
launch_template_data = TemplateData(
user_data=b64encode(
"This is some user_data".encode(encoding_format_utf_8)
).decode(encoding_format_utf_8),
associate_public_ip_address=True,
)
launch_template_versions = [
LaunchTemplateVersion(
version_number=1,
template_data=launch_template_data,
),
]
launch_template = LaunchTemplate(
name=launch_template_name,
id=launch_template_id,
arn=launch_template_arn,
region=AWS_REGION_US_EAST_1,
versions=launch_template_versions,
)
ec2_client.launch_templates = [launch_template]
ec2_client.audit_config = {"secrets_validate": True}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=ec2_client,
),
mock.patch(
"prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets.ec2_client",
new=ec2_client,
),
mock.patch(
"prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets.detect_secrets_scan_batch",
return_value={
(0, 0): [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 1,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
# Test Check
from prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets import (
ec2_launch_template_no_secrets,
)
check = ec2_launch_template_no_secrets()
result = check.execute()
# The check must forward secrets_validate from the config to the scan.
assert mock_scan.call_args.kwargs.get("validate") is True
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert result[0].resource_id == launch_template_id
@mock_aws
def test_one_launch_template_without_user_data(self):
launch_template_name = "tester"
@@ -506,7 +583,7 @@ class Test_ec2_launch_template_no_secrets:
launch_template_secrets,
launch_template_no_secrets,
]
ec2_client.audit_config = {"detect_secrets_plugins": None}
ec2_client.audit_config = {}
with (
mock.patch(
@@ -530,7 +607,7 @@ class Test_ec2_launch_template_no_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name1} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4."
== f"Potential secret found in User Data for EC2 Launch Template {launch_template_name1} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4."
)
assert result[0].resource_id == launch_template_id1
assert result[0].region == AWS_REGION_US_EAST_1
@@ -593,10 +670,10 @@ class Test_ec2_launch_template_no_secrets:
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== f"No secrets found in User Data of any version for EC2 Launch Template {launch_template_name}."
f"Could not decode User Data for EC2 Launch Template {launch_template_name}"
in result[0].status_extended
)
assert result[0].resource_id == launch_template_id
assert result[0].region == AWS_REGION_US_EAST_1
@@ -1,4 +1,4 @@
DB_PASSWORD=foobar123
DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"
DB_USER=foo
API_KEY=12345abcd
SERVICE_PASSWORD=bbaabb45
STRIPE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4"
@@ -11,9 +11,17 @@ CONTAINER_NAME = "test-container"
ENV_VAR_NAME_NO_SECRETS = "host"
ENV_VAR_VALUE_NO_SECRETS = "localhost:1234"
ENV_VAR_NAME_WITH_KEYWORD = "DB_PASSWORD"
ENV_VAR_VALUE_WITH_SECRETS = "srv://admin:pass@db"
# Realistic fake secrets that Kingfisher actually detects (placeholders such as
# the previous "srv://admin:pass@db" basic-auth URL are no longer flagged).
# A JWT fires on any line of the dumped JSON (even when followed by a
# trailing comma); a keyword-named variable additionally fires the generic
# keyword rule when it is the last entry in the dump.
ENV_VAR_VALUE_WITH_SECRETS = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
ENV_VAR_NAME_WITH_KEYWORD2 = "DATABASE_PASSWORD"
ENV_VAR_VALUE_WITH_SECRETS2 = "srv://admin:password@database"
ENV_VAR_VALUE_WITH_SECRETS2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEwIiwibmFtZSI6IkphbmUifQ.s5LqY8mC2pX1vN0bQwReTyUiOpAsDfGhJkLzXcVbNm0"
# Generic password/secret assignment value (detected only on the last entry of
# the JSON dump, where there is no trailing comma after the value).
ENV_VAR_VALUE_GENERIC_SECRET = "Tr0ub4dor3xKq9vLmZ"
class Test_ecs_task_definitions_no_environment_secrets:
@@ -143,7 +151,7 @@ class Test_ecs_task_definitions_no_environment_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Basic Auth Credentials on the environment variable host."
== f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> JSON Web Token (base64url-encoded) on the environment variable host."
)
assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}"
assert result[0].resource_arn == task_arn
@@ -167,7 +175,7 @@ class Test_ecs_task_definitions_no_environment_secrets:
"environment": [
{
"name": ENV_VAR_NAME_WITH_KEYWORD,
"value": ENV_VAR_VALUE_NO_SECRETS,
"value": ENV_VAR_VALUE_GENERIC_SECRET,
}
],
}
@@ -198,7 +206,7 @@ class Test_ecs_task_definitions_no_environment_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD."
== f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Generic Password on the environment variable DB_PASSWORD."
)
assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}"
assert result[0].resource_arn == task_arn
@@ -251,9 +259,20 @@ class Test_ecs_task_definitions_no_environment_secrets:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
# The keyword-named variable holding a real secret triggers both the
# generic keyword rule and the JWT rule on the same line.
# Kingfisher emits same-line findings in a non-deterministic order, so
# assert both are present without pinning their order.
assert result[0].status_extended.startswith(
f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> "
)
assert (
result[0].status_extended
== f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD."
"JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD"
in result[0].status_extended
)
assert (
"Generic Password on the environment variable DB_PASSWORD"
in result[0].status_extended
)
assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}"
assert result[0].resource_arn == task_arn
@@ -310,9 +329,23 @@ class Test_ecs_task_definitions_no_environment_secrets:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
# DB_PASSWORD holds a JWT under a keyword name, so it fires
# both the JWT rule and the generic keyword rule on the
# same line (non-deterministic order); host holds a second JWT.
assert result[0].status_extended.startswith(
f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> "
)
assert (
result[0].status_extended
== f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable host."
"JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD"
in result[0].status_extended
)
assert (
"Generic Password on the environment variable DB_PASSWORD"
in result[0].status_extended
)
assert (
"JSON Web Token (base64url-encoded) on the environment variable host"
in result[0].status_extended
)
assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}"
assert result[0].resource_arn == task_arn
@@ -340,7 +373,7 @@ class Test_ecs_task_definitions_no_environment_secrets:
},
{
"name": ENV_VAR_NAME_WITH_KEYWORD2,
"value": ENV_VAR_VALUE_WITH_SECRETS2,
"value": ENV_VAR_VALUE_GENERIC_SECRET,
},
],
}
@@ -369,9 +402,24 @@ class Test_ecs_task_definitions_no_environment_secrets:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
# DB_PASSWORD holds a JWT under a keyword name, so it fires
# both the JWT and the generic keyword rule on the same line
# (non-deterministic order); DATABASE_PASSWORD fires the generic
# keyword rule on its own line.
assert result[0].status_extended.startswith(
f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> "
)
assert (
result[0].status_extended
== f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DATABASE_PASSWORD, Secret Keyword on the environment variable DATABASE_PASSWORD."
"JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD"
in result[0].status_extended
)
assert (
"Generic Password on the environment variable DB_PASSWORD"
in result[0].status_extended
)
assert (
"Generic Password on the environment variable DATABASE_PASSWORD"
in result[0].status_extended
)
assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}"
assert result[0].resource_arn == task_arn
@@ -27,13 +27,13 @@ class Test_ssm_documents_secrets:
document_name = "test-document"
document_arn = f"arn:aws:ssm:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:document/{document_name}"
ssm_client.audited_account = AWS_ACCOUNT_NUMBER
ssm_client.audit_config = {"detect_secrets_plugins": None}
ssm_client.audit_config = {}
ssm_client.documents = {
document_name: Document(
arn=document_arn,
name=document_name,
region=AWS_REGION_US_EAST_1,
content={"db_password": "test-password"},
content={"db_password": "Tr0ub4dor3xKq9vLmZ"},
account_owners=[],
)
}
@@ -56,7 +56,7 @@ class Test_ssm_documents_secrets:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Potential secret found in SSM Document {document_name} -> Secret Keyword on line 2."
== f"Potential secret found in SSM Document {document_name} -> Generic Password on line 2."
)
def test_document_no_secrets(self):
@@ -147,7 +147,7 @@ class Test_stepfunctions_statemachine_no_secrets_in_definition:
arn=statemachine_arn,
name="TestStateMachine",
status=StateMachineStatus.ACTIVE,
definition='{"Comment": "Example with secret", "StartAt": "MyTask", "States": {"MyTask": {"Type": "Task", "Parameters": {"api_key": "AKIAIOSFODNN7EXAMPLE"}, "End": true}}}',
definition='{"Comment": "Example with secret", "StartAt": "MyTask", "States": {"MyTask": {"Type": "Task", "Parameters": {"api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}, "End": true}}}',
region=AWS_REGION_US_EAST_1,
type=StateMachineType.STANDARD,
creation_date=datetime.now(),
@@ -2,6 +2,7 @@
from unittest import mock
from prowler.lib.check.models import Severity
from prowler.providers.openstack.services.blockstorage.blockstorage_service import (
SnapshotResource,
)
@@ -141,7 +142,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data:
status="available",
size=50,
volume_id="vol-1",
metadata={"db_password": "supersecret123"},
metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"},
project_id=OPENSTACK_PROJECT_ID,
region=OPENSTACK_REGION,
)
@@ -179,7 +180,9 @@ class Test_blockstorage_snapshot_metadata_sensitive_data:
status="available",
size=50,
volume_id="vol-1",
metadata={"api_key": "sk-1234567890"},
metadata={
"api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
},
project_id=OPENSTACK_PROJECT_ID,
region=OPENSTACK_REGION,
)
@@ -223,7 +226,9 @@ class Test_blockstorage_snapshot_metadata_sensitive_data:
status="available",
size=50,
volume_id="vol-1",
metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"},
metadata={
"ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n"
},
project_id=OPENSTACK_PROJECT_ID,
region=OPENSTACK_REGION,
)
@@ -277,7 +282,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data:
status="available",
size=50,
volume_id="vol-2",
metadata={"admin_password": "secret123"},
metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"},
project_id=OPENSTACK_PROJECT_ID,
region=OPENSTACK_REGION,
),
@@ -318,7 +323,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data:
metadata={
"environment": "production",
"application": "web-app",
"db_password": "supersecret123",
"db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"region": "us-east",
},
project_id=OPENSTACK_PROJECT_ID,
@@ -348,3 +353,57 @@ class Test_blockstorage_snapshot_metadata_sensitive_data:
# Verify the secret is correctly attributed to 'db_password' key
assert "in metadata key 'db_password'" in result[0].status_extended
assert result[0].resource_id == "snap-6"
def test_snapshot_verified_secret_escalates_to_critical(self):
"""Test that a confirmed live secret escalates the finding to CRITICAL (FAIL)."""
blockstorage_client = mock.MagicMock()
blockstorage_client.audit_config = {"secrets_validate": True}
blockstorage_client.snapshots = [
SnapshotResource(
id="snap-verified",
name="Verified Secret",
status="available",
size=50,
volume_id="vol-1",
metadata={"api_key": "placeholder"},
project_id=OPENSTACK_PROJECT_ID,
region=OPENSTACK_REGION,
)
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_openstack_provider(),
),
mock.patch(
"prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data.blockstorage_client",
new=blockstorage_client,
),
mock.patch(
"prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data.detect_secrets_scan_batch",
return_value={
0: [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 2,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
from prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data import (
blockstorage_snapshot_metadata_sensitive_data,
)
check = blockstorage_snapshot_metadata_sensitive_data()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert mock_scan.call_args.kwargs.get("validate") is True
@@ -2,6 +2,7 @@
from unittest import mock
from prowler.lib.check.models import Severity
from prowler.providers.openstack.services.blockstorage.blockstorage_service import (
VolumeResource,
)
@@ -159,7 +160,7 @@ class Test_blockstorage_volume_metadata_sensitive_data:
is_bootable=False,
is_multiattach=False,
attachments=[],
metadata={"db_password": "supersecret123"},
metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"},
availability_zone="nova",
snapshot_id="",
source_volume_id="",
@@ -204,7 +205,9 @@ class Test_blockstorage_volume_metadata_sensitive_data:
is_bootable=False,
is_multiattach=False,
attachments=[],
metadata={"api_key": "sk-1234567890"},
metadata={
"api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
},
availability_zone="nova",
snapshot_id="",
source_volume_id="",
@@ -255,7 +258,9 @@ class Test_blockstorage_volume_metadata_sensitive_data:
is_bootable=False,
is_multiattach=False,
attachments=[],
metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"},
metadata={
"ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n"
},
availability_zone="nova",
snapshot_id="",
source_volume_id="",
@@ -323,7 +328,7 @@ class Test_blockstorage_volume_metadata_sensitive_data:
is_bootable=False,
is_multiattach=False,
attachments=[],
metadata={"admin_password": "secret123"},
metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"},
availability_zone="nova",
snapshot_id="",
source_volume_id="",
@@ -371,7 +376,7 @@ class Test_blockstorage_volume_metadata_sensitive_data:
metadata={
"environment": "production",
"application": "web-app",
"db_password": "supersecret123",
"db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"region": "us-east",
},
availability_zone="nova",
@@ -404,3 +409,64 @@ class Test_blockstorage_volume_metadata_sensitive_data:
# Verify the secret is correctly attributed to 'db_password' key
assert "in metadata key 'db_password'" in result[0].status_extended
assert result[0].resource_id == "vol-6"
def test_volume_verified_secret_escalates_to_critical(self):
"""Test that a confirmed live secret escalates the finding to CRITICAL (FAIL)."""
blockstorage_client = mock.MagicMock()
blockstorage_client.audit_config = {"secrets_validate": True}
blockstorage_client.volumes = [
VolumeResource(
id="vol-verified",
name="Verified Secret",
status="in-use",
size=100,
volume_type="standard",
is_encrypted=False,
is_bootable=False,
is_multiattach=False,
attachments=[],
metadata={"api_key": "placeholder"},
availability_zone="nova",
snapshot_id="",
source_volume_id="",
project_id=OPENSTACK_PROJECT_ID,
region=OPENSTACK_REGION,
)
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_openstack_provider(),
),
mock.patch(
"prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data.blockstorage_client",
new=blockstorage_client,
),
mock.patch(
"prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data.detect_secrets_scan_batch",
return_value={
0: [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 2,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
from prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data import (
blockstorage_volume_metadata_sensitive_data,
)
check = blockstorage_volume_metadata_sensitive_data()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert mock_scan.call_args.kwargs.get("validate") is True
@@ -2,6 +2,7 @@
from unittest import mock
from prowler.lib.check.models import Severity
from prowler.providers.openstack.services.compute.compute_service import ComputeInstance
from tests.providers.openstack.openstack_fixtures import (
OPENSTACK_PROJECT_ID,
@@ -181,7 +182,7 @@ class Test_compute_instance_metadata_sensitive_data:
private_v6="",
networks={},
has_config_drive=False,
metadata={"db_password": "supersecret123"},
metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"},
user_data="",
trusted_image_certificates=[],
)
@@ -233,7 +234,9 @@ class Test_compute_instance_metadata_sensitive_data:
private_v6="",
networks={},
has_config_drive=False,
metadata={"api_key": "sk-1234567890"},
metadata={
"api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
},
user_data="",
trusted_image_certificates=[],
)
@@ -349,7 +352,9 @@ class Test_compute_instance_metadata_sensitive_data:
private_v6="",
networks={},
has_config_drive=False,
metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"},
metadata={
"ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n"
},
user_data="",
trusted_image_certificates=[],
)
@@ -431,7 +436,7 @@ class Test_compute_instance_metadata_sensitive_data:
private_v6="",
networks={},
has_config_drive=False,
metadata={"admin_password": "secret123"},
metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"},
user_data="",
trusted_image_certificates=[],
),
@@ -486,7 +491,7 @@ class Test_compute_instance_metadata_sensitive_data:
metadata={
"environment": "production",
"application": "web-app",
"db_password": "supersecret123",
"db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"region": "us-east",
},
user_data="",
@@ -544,7 +549,7 @@ class Test_compute_instance_metadata_sensitive_data:
has_config_drive=False,
metadata={
"first_key": "safe_value",
"api_key": "sk-1234567890abcdef",
"api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"third_key": "also_safe",
},
user_data="",
@@ -574,3 +579,128 @@ class Test_compute_instance_metadata_sensitive_data:
# Verify the secret is correctly attributed to 'api_key' key (second in order)
assert "in metadata key 'api_key'" in result[0].status_extended
assert result[0].resource_id == "instance-8"
def test_instance_verified_secret_escalates_to_critical(self):
"""Test that a confirmed live secret escalates the finding to CRITICAL (FAIL)."""
compute_client = mock.MagicMock()
compute_client.audit_config = {"secrets_validate": True}
compute_client.instances = [
ComputeInstance(
id="instance-verified",
name="Verified Secret",
status="ACTIVE",
flavor_id="flavor-1",
security_groups=["default"],
region=OPENSTACK_REGION,
project_id=OPENSTACK_PROJECT_ID,
is_locked=False,
locked_reason="",
key_name="",
user_id="",
access_ipv4="",
access_ipv6="",
public_v4="",
public_v6="",
private_v4="",
private_v6="",
networks={},
has_config_drive=False,
metadata={"api_key": "placeholder"},
user_data="",
trusted_image_certificates=[],
)
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_openstack_provider(),
),
mock.patch(
"prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.compute_client",
new=compute_client,
),
mock.patch(
"prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.detect_secrets_scan_batch",
return_value={
0: [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 2,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
from prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data import (
compute_instance_metadata_sensitive_data,
)
check = compute_instance_metadata_sensitive_data()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert mock_scan.call_args.kwargs.get("validate") is True
def test_scan_failure_reports_manual(self):
from prowler.lib.utils.utils import SecretsScanError
compute_client = mock.MagicMock()
compute_client.audit_config = {"secrets_ignore_patterns": []}
compute_client.instances = [
ComputeInstance(
id="instance-scan-fail",
name="Scan Fail",
status="ACTIVE",
flavor_id="flavor-1",
security_groups=["default"],
region=OPENSTACK_REGION,
project_id=OPENSTACK_PROJECT_ID,
is_locked=False,
locked_reason="",
key_name="",
user_id="",
access_ipv4="",
access_ipv6="",
public_v4="",
public_v6="",
private_v4="",
private_v6="",
networks={},
has_config_drive=False,
metadata={"api_key": "placeholder"},
user_data="",
trusted_image_certificates=[],
)
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_openstack_provider(),
),
mock.patch(
"prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.compute_client",
new=compute_client,
),
mock.patch(
"prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher exited with code 1"),
),
):
from prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data import (
compute_instance_metadata_sensitive_data,
)
check = compute_instance_metadata_sensitive_data()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not scan" in result[0].status_extended
@@ -2,6 +2,7 @@
from unittest import mock
from prowler.lib.check.models import Severity
from prowler.providers.openstack.services.objectstorage.objectstorage_service import (
ObjectStorageContainer,
)
@@ -157,7 +158,7 @@ class Test_objectstorage_container_metadata_sensitive_data:
history_location="",
sync_to="",
sync_key="",
metadata={"db_password": "supersecret123"},
metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"},
)
]
@@ -217,7 +218,7 @@ class Test_objectstorage_container_metadata_sensitive_data:
history_location="",
sync_to="",
sync_key="",
metadata={"admin_password": "secret123"},
metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"},
),
]
@@ -241,3 +242,63 @@ class Test_objectstorage_container_metadata_sensitive_data:
assert len(result) == 2
assert len([r for r in result if r.status == "PASS"]) == 1
assert len([r for r in result if r.status == "FAIL"]) == 1
def test_container_verified_secret_escalates_to_critical(self):
"""Test that a confirmed live secret escalates the finding to CRITICAL (FAIL)."""
objectstorage_client = mock.MagicMock()
objectstorage_client.audit_config = {"secrets_validate": True}
objectstorage_client.containers = [
ObjectStorageContainer(
id="container-verified",
name="verified-secret",
region=OPENSTACK_REGION,
project_id=OPENSTACK_PROJECT_ID,
object_count=0,
bytes_used=0,
read_ACL="",
write_ACL="",
versioning_enabled=False,
versions_location="",
history_location="",
sync_to="",
sync_key="",
metadata={"api_key": "placeholder"},
)
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_openstack_provider(),
),
mock.patch(
"prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data.objectstorage_client",
new=objectstorage_client,
),
mock.patch(
"prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data.detect_secrets_scan_batch",
return_value={
0: [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 2,
"filename": "data",
"hashed_secret": "x",
"is_verified": True,
}
]
},
) as mock_scan,
):
from prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data import (
objectstorage_container_metadata_sensitive_data,
)
check = objectstorage_container_metadata_sensitive_data()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
assert mock_scan.call_args.kwargs.get("validate") is True