feat(apigateway): add check for secrets in REST API stage variables (#11188)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Chirag Trivedi
2026-07-01 17:54:18 +05:30
committed by GitHub
parent 1e1c1c018b
commit 3f8c1e822f
8 changed files with 458 additions and 0 deletions
@@ -26,6 +26,7 @@ The following list includes all the AWS checks with configurable variables that
|---------------------------------------------------------------|--------------------------------------------------|-----------------|
| `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer |
| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings |
| `apigateway_restapi_no_secrets_in_stage_variables` | `secrets_ignore_patterns` | List of Strings |
| `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer |
| `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer |
| `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer |
@@ -12,6 +12,7 @@ The checks with this functionality are the following.
AWS:
- apigateway\_restapi\_no\_secrets\_in\_stage\_variables
- autoscaling\_find\_secrets\_ec2\_launch\_configuration
- awslambda\_function\_no\_secrets\_in\_code
- awslambda\_function\_no\_secrets\_in\_variables
+1
View File
@@ -24,6 +24,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- CIS GitHub Benchmark v1.2.0 compliance framework for the GitHub provider [(#11719)](https://github.com/prowler-cloud/prowler/pull/11719)
- AWS Bedrock AgentCore privilege escalation paths in the IAM privilege escalation checks, covering Runtime, Harness, Code Interpreter and Custom Browser [(#11726)](https://github.com/prowler-cloud/prowler/pull/11726)
- `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
- `apigateway_restapi_no_secrets_in_stage_variables` check for AWS provider, scanning API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens [(#11188)](https://github.com/prowler-cloud/prowler/pull/11188)
### 🔄 Changed
@@ -0,0 +1,39 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_no_secrets_in_stage_variables",
"CheckTitle": "API Gateway REST API stage variables should not contain secrets",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "apigateway",
"SubServiceName": "",
"ResourceIdTemplate": "arn:aws:apigateway:region::/restapis/api-id/stages/stage-name",
"Severity": "high",
"ResourceType": "AwsApiGatewayStage",
"ResourceGroup": "security",
"Description": "Checks API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens. Stage variables should reference AWS Secrets Manager or Parameter Store rather than containing plaintext credentials.",
"Risk": "Hardcoded secrets in stage variables are stored in plaintext in the AWS control plane and are visible to anyone with read access to the API Gateway configuration. This can lead to unauthorized access, credential theft, and lateral movement across systems.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_how-services-use-secrets_api-gateway.html"
],
"Remediation": {
"Code": {
"CLI": "aws apigateway update-stage --rest-api-id <api-id> --stage-name <stage-name> --patch-operations op=remove,path=/variables/<variable-name>",
"NativeIaC": "",
"Other": "1. Open AWS Console > API Gateway\n2. Select the REST API and stage\n3. Go to Stage Variables tab\n4. Remove any variables containing plaintext secrets\n5. Reference secrets using AWS Secrets Manager integration instead",
"Terraform": ""
},
"Recommendation": {
"Text": "Remove hardcoded secrets from API Gateway stage variables. Use AWS Secrets Manager or Parameter Store to manage credentials and retrieve them at runtime using Lambda authorizers or integration request mapping templates.",
"Url": "https://hub.prowler.com/check/apigateway_restapi_no_secrets_in_stage_variables"
}
},
"Categories": [
"secrets"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Infrastructure Protection"
}
@@ -0,0 +1,89 @@
import json
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.utils.utils import (
SecretsScanError,
annotate_verified_secrets,
detect_secrets_scan_batch,
)
from prowler.providers.aws.services.apigateway.apigateway_client import (
apigateway_client,
)
class apigateway_restapi_no_secrets_in_stage_variables(Check):
"""Check that API Gateway REST API stage variables contain no hardcoded secrets."""
def execute(self) -> list[Check_Report_AWS]:
findings = []
secrets_ignore_patterns = apigateway_client.audit_config.get(
"secrets_ignore_patterns", []
)
validate = apigateway_client.audit_config.get("secrets_validate", False)
# Collect one payload per stage (its variables) and scan them all in
# batched Kingfisher invocations instead of one subprocess per stage.
# Findings are keyed by (rest_api index, stage index).
def payloads():
for api_index, rest_api in enumerate(apigateway_client.rest_apis):
for stage_index, stage in enumerate(rest_api.stages):
if stage.variables:
yield (api_index, stage_index), json.dumps(
stage.variables, indent=2
)
scan_error = None
try:
batch_results = detect_secrets_scan_batch(
payloads(),
excluded_secrets=secrets_ignore_patterns,
validate=validate,
)
except SecretsScanError as error:
batch_results = {}
scan_error = error
for api_index, rest_api in enumerate(apigateway_client.rest_apis):
for stage_index, stage in enumerate(rest_api.stages):
report = Check_Report_AWS(metadata=self.metadata(), resource=rest_api)
report.resource_arn = stage.arn
report.resource_id = f"{rest_api.name}/{stage.name}"
report.status = "PASS"
report.status_extended = (
f"No secrets found in stage variables of API Gateway "
f"REST API {rest_api.name} stage {stage.name}."
)
if stage.variables:
if scan_error:
report.status = "MANUAL"
report.status_extended = (
f"Could not scan stage variables of API Gateway REST API "
f"{rest_api.name} stage {stage.name} for secrets: "
f"{scan_error}; manual review is required."
)
findings.append(report)
continue
detect_secrets_output = batch_results.get((api_index, stage_index))
if detect_secrets_output:
variable_names = list(stage.variables.keys())
secrets_string = ", ".join(
[
f"{secret['type']} in variable "
f"{variable_names[secret['line_number'] - 2]}"
for secret in detect_secrets_output
]
)
report.status = "FAIL"
report.status_extended = (
f"Potential "
f"{'secrets' if len(detect_secrets_output) > 1 else 'secret'} "
f"found in stage variables of API Gateway REST API "
f"{rest_api.name} stage {stage.name} -> {secrets_string}."
)
annotate_verified_secrets(report, detect_secrets_output)
findings.append(report)
return findings
@@ -179,6 +179,7 @@ class APIGateway(AWSService):
tracing_enabled=tracing_enabled,
cache_enabled=cache_enabled,
cache_data_encrypted=cache_data_encrypted,
variables=stage.get("variables", {}),
)
)
except ClientError as error:
@@ -265,6 +266,7 @@ class Stage(BaseModel):
tracing_enabled: Optional[bool] = None
cache_enabled: Optional[bool] = None
cache_data_encrypted: Optional[bool] = None
variables: Optional[dict] = {}
class PathResourceMethods(BaseModel):
@@ -0,0 +1,325 @@
from unittest import mock
from boto3 import client
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_REGION_EU_WEST_1,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
class Test_apigateway_restapi_no_secrets_in_stage_variables:
@mock_aws
def test_no_rest_apis(self):
from prowler.providers.aws.services.apigateway.apigateway_service import (
APIGateway,
)
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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
new=APIGateway(aws_provider),
),
):
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
apigateway_restapi_no_secrets_in_stage_variables,
)
check = apigateway_restapi_no_secrets_in_stage_variables()
result = check.execute()
assert len(result) == 0
@mock_aws
def test_stage_with_no_variables(self):
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
rest_api = apigw.create_rest_api(name="test-api")
api_id = rest_api["id"]
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
resource = apigw.create_resource(
restApiId=api_id, parentId=root_id, pathPart="test"
)
apigw.put_method(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
authorizationType="NONE",
)
apigw.put_integration(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
type="HTTP",
integrationHttpMethod="POST",
uri="http://test.com",
)
apigw.create_deployment(restApiId=api_id, stageName="prod")
from prowler.providers.aws.services.apigateway.apigateway_service import (
APIGateway,
)
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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
new=APIGateway(aws_provider),
),
):
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
apigateway_restapi_no_secrets_in_stage_variables,
)
check = apigateway_restapi_no_secrets_in_stage_variables()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
"No secrets found in stage variables of API Gateway "
"REST API test-api stage prod."
)
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "test-api/prod"
@mock_aws
def test_stage_with_safe_variables(self):
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
rest_api = apigw.create_rest_api(name="test-api")
api_id = rest_api["id"]
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
resource = apigw.create_resource(
restApiId=api_id, parentId=root_id, pathPart="test"
)
apigw.put_method(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
authorizationType="NONE",
)
apigw.put_integration(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
type="HTTP",
integrationHttpMethod="POST",
uri="http://test.com",
)
apigw.create_deployment(restApiId=api_id, stageName="prod")
apigw.update_stage(
restApiId=api_id,
stageName="prod",
patchOperations=[
{
"op": "replace",
"path": "/variables/environment",
"value": "production",
},
{"op": "replace", "path": "/variables/region", "value": "us-east-1"},
],
)
from prowler.providers.aws.services.apigateway.apigateway_service import (
APIGateway,
)
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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
new=APIGateway(aws_provider),
),
):
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
apigateway_restapi_no_secrets_in_stage_variables,
)
check = apigateway_restapi_no_secrets_in_stage_variables()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
"No secrets found in stage variables of API Gateway "
"REST API test-api stage prod."
)
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "test-api/prod"
@mock_aws
def test_stage_with_secrets_in_variables(self):
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
rest_api = apigw.create_rest_api(name="test-api")
api_id = rest_api["id"]
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
resource = apigw.create_resource(
restApiId=api_id, parentId=root_id, pathPart="test"
)
apigw.put_method(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
authorizationType="NONE",
)
apigw.put_integration(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
type="HTTP",
integrationHttpMethod="POST",
uri="http://test.com",
)
apigw.create_deployment(restApiId=api_id, stageName="prod")
# A safe variable is added alongside the secret so the secret is not the
# only variable present. This guards the line-number -> variable-name
# mapping against an off-by-one that would otherwise still point at the
# single variable and pass unnoticed.
# A syntactically valid JSON Web Token that Kingfisher flags as a secret.
apigw.update_stage(
restApiId=api_id,
stageName="prod",
patchOperations=[
{
"op": "replace",
"path": "/variables/environment",
"value": "production",
},
{
"op": "replace",
"path": "/variables/api_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
},
],
)
from prowler.providers.aws.services.apigateway.apigateway_service import (
APIGateway,
)
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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
new=APIGateway(aws_provider),
),
):
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
apigateway_restapi_no_secrets_in_stage_variables,
)
check = apigateway_restapi_no_secrets_in_stage_variables()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "test-api" in result[0].status_extended
assert "prod" in result[0].status_extended
assert "in variable api_token" in result[0].status_extended
# The secret must be attributed to the correct variable, not the
# safe one that precedes it.
assert "in variable environment" not in result[0].status_extended
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "test-api/prod"
@mock_aws
def test_stage_with_variables_scan_error(self):
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
rest_api = apigw.create_rest_api(name="test-api")
api_id = rest_api["id"]
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
resource = apigw.create_resource(
restApiId=api_id, parentId=root_id, pathPart="test"
)
apigw.put_method(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
authorizationType="NONE",
)
apigw.put_integration(
restApiId=api_id,
resourceId=resource["id"],
httpMethod="GET",
type="HTTP",
integrationHttpMethod="POST",
uri="http://test.com",
)
apigw.create_deployment(restApiId=api_id, stageName="prod")
apigw.update_stage(
restApiId=api_id,
stageName="prod",
patchOperations=[
{"op": "replace", "path": "/variables/api_token", "value": "value"},
],
)
from prowler.lib.utils.utils import SecretsScanError
from prowler.providers.aws.services.apigateway.apigateway_service import (
APIGateway,
)
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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
new=APIGateway(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.detect_secrets_scan_batch",
side_effect=SecretsScanError("Kingfisher failed"),
),
):
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
apigateway_restapi_no_secrets_in_stage_variables,
)
check = apigateway_restapi_no_secrets_in_stage_variables()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "manual review is required" in result[0].status_extended
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "test-api/prod"