Skip to content

Commit 7b2fea5

Browse files
committed
Add GitHub action for validating IAM policies in CloudFormation templates
1 parent f424bb0 commit 7b2fea5

File tree

3 files changed

+280
-0
lines changed

3 files changed

+280
-0
lines changed

Dockerfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# FROM public.ecr.aws/lambda/python:3.10
2+
FROM python:3.10
3+
# Install cfn-policy-validator
4+
RUN pip install cfn-policy-validator==0.0.31
5+
6+
COPY main.py /main.py
7+
8+
ENTRYPOINT ["python3", "/main.py"]

action.yaml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: 'Policy checks to validate AWS IAM policies in CloudFormation templates" Action For GitHub Actions'
2+
description: "Uses ValidatePolicy, CheckAccessNotGranted, CheckNoNewAccess APIs from AWS Access Analyzer for policy checks - https://docs.aws.amazon.com/access-analyzer/latest/APIReference/"
3+
branding:
4+
icon: "cloud"
5+
color: "orange"
6+
inputs:
7+
policy-check-type:
8+
description: "Type of the policy check. Valid values: VALIDATE_POLICY, CHECK_NO_NEW_ACCESS, CHECK_ACCESS_NOT_GRANTED"
9+
required: true
10+
template-path:
11+
description: "The path to the CloudFormation template."
12+
required: true
13+
region:
14+
description: "The destination region the resources will be deployed to."
15+
required: true
16+
parameters:
17+
description: "Keys and values for CloudFormation template parameters. Only parameters that are referenced by IAM policies in the template are required. Example format - KEY=VALUE [KEY=VALUE ...]"
18+
template-configuration-file:
19+
description: "A JSON formatted file that specifies template parameter values, a stack policy, and tags. Only parameters are used from this file. Everything else is ignored. Identical values passed in the --parameters flag override parameters in this file. See CloudFormation documentation for file format: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/continuous-delivery-codepipeline-cfn-artifacts.html"
20+
ignore-finding:
21+
description: 'Allow validation failures to be ignored. Specify as a comma separated list of findings to be ignored. Can be individual finding codes (e.g. "PASS_ROLE_WITH_STAR_IN_RESOURCE"), a specific resource name (e.g. "MyResource"), or a combination of both separated by a period.(e.g. "MyResource.PASS_ROLE_WITH_STAR_IN_RESOURCE"). Names of finding codes may change in IAM Access Analyzer over time. Valid options: FINDING_CODE,RESOURCE_NAME,RESOURCE_NAME.FINDING_CODE'
22+
actions:
23+
description: 'List of comma-separated actions. Example format - ACTION,ACTION,ACTION. This attribute is considered and required when policy-check-type is "CHECK_ACCESS_NOT_GRANTED"'
24+
reference-policy:
25+
description: 'A JSON formatted file that specifies the path to the reference policy that is used for a permissions comparison. This attribute is considered and required when policy-check-type is "CHECK_NO_NEW_ACCESS"'
26+
reference-policy-type:
27+
description: 'The policy type associated with the IAM policy under analysis and the reference policy. Valid values: IDENTITY, RESOURCE. This attribute is considered and required when policy-check-type is "CHECK_NO_NEW_ACCESS"'
28+
treat-finding-type-as-blocking:
29+
description: 'Specify which finding types should be treated as blocking. Other finding types are treated as non blocking. If the tool detects any blocking finding types, it will exit with a non-zero exit code. If all findings are non blocking or there are no findings, the tool exits with an exit code of 0. Defaults to "ERROR" and "SECURITY_WARNING". Specify as a comma separated list of finding types that should be blocking. Pass "NONE" to ignore all findings. This attribute is considered only when policy-check-type is "VALIDATE_POLICY"'
30+
treat-findings-as-non-blocking:
31+
description: 'When not specified, the tool detects any findings, it will exit with a non-zero exit code. When specified, the tool exits with an exit code of 0. This attribute is considered only when policy-check-type is "CHECK_NO_NEW_ACCESS" or "CHECK_ACCESS_NOT_GRANTED"'
32+
default: "False"
33+
allow-external-principals:
34+
description: 'A comma separated list of external principals that should be ignored. Specify as a comma separated list of a 12 digit AWS account ID, a federated web identity user, a federated SAML user, or an ARN. Specify \"*\" to allow anonymous access. (e.g. 123456789123,arn:aws:iam::111111111111:role/MyOtherRole,graph.facebook.com). Valid options: ACCOUNT,ARN". This attribute is considered only when policy-check-type is "VALIDATE_POLICY"'
35+
allow-dynamic-ref-without-version:
36+
description: "Override the default behavior and allow dynamic SSM references without version numbers. The version number ensures that the SSM parameter value that was validated is the one that is deployed."
37+
exclude-resource-types:
38+
description: "List of comma-separated resource types. Resource types should be the same as Cloudformation template resource names such as AWS::IAM::Role, AWS::S3::Bucket. Valid option syntax: AWS::SERVICE::RESOURCE"
39+
outputs:
40+
result:
41+
description: "Result of the policy checks"
42+
runs:
43+
using: "docker"
44+
image: Dockerfile
45+
args:
46+
- ${{ inputs.policy-check-type}}
47+
- ${{ inputs.template-path }}
48+
- ${{ inputs.region }}
49+
- ${{ inputs.parameters }}
50+
- ${{ inputs.template-configuration-file }}
51+
- ${{ inputs.ignore-finding }}
52+
- ${{ inputs.actions }}
53+
- ${{ inputs.reference-policy }}
54+
- ${{ inputs.reference-policy-type }}
55+
- ${{ inputs.treat-finding-type-as-blocking }}
56+
- ${{ inputs.treat-findings-as-non-blocking }}
57+
- ${{ inputs.allow-external-principals }}
58+
- ${{ inputs.allow-dynamic-ref-without-version }}
59+
- ${{ inputs.exclude-resource-types }}

main.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import os
2+
import re
3+
import subprocess
4+
import sys
5+
import traceback
6+
7+
VALIDATE_POLICY = "VALIDATE_POLICY"
8+
CHECK_NO_NEW_ACCESS = "CHECK_NO_NEW_ACCESS"
9+
CHECK_ACCESS_NOT_GRANTED = "CHECK_ACCESS_NOT_GRANTED"
10+
11+
CLI_POLICY_VALIDATOR = "cfn-policy-validator"
12+
13+
TREAT_FINDINGS_AS_NON_BLOCKING = "INPUT_TREAT-FINDINGS-AS-NON-BLOCKING"
14+
15+
POLICY_CHECK_TYPE = "INPUT_POLICY-CHECK-TYPE"
16+
17+
# excluding the "INPUT_POLICY-CHECK-TYPE". Contains only other required inputs in cfn-policy-validator
18+
COMMON_REQUIRED_INPUTS = {"INPUT_TEMPLATE-PATH", "INPUT_REGION"}
19+
20+
VALIDATE_POLICY_SPECIFIC_REQUIRED_INPUTS = set()
21+
22+
CHECK_NO_NEW_ACCESS_SPECIFIC_REQUIRED_INPUTS = {
23+
"INPUT_TEMPLATE-PATH",
24+
"INPUT_REGION",
25+
"INPUT_REFERENCE-POLICY",
26+
"INPUT_REFERENCE-POLICY-TYPE",
27+
}
28+
29+
CHECK_ACCESS_NOT_GRANTED_SPECIFIC_REQUIRED_INPUTS = {"INPUT_ACTIONS"}
30+
31+
# excluding the "INPUT_POLICY-CHECK-TYPE". Contains only other required inputs in cfn-policy-validator
32+
COMMON_OPTIONAL_INPUTS = {
33+
"INPUT_PARAMETERS",
34+
"INPUT_TEMPLATE-CONFIGURATION-FILE",
35+
"INPUT_IGNORE-FINDING",
36+
"INPUT_ALLOW-DYNAMIC-REF-WITHOUT-VERSION",
37+
"INPUT_EXCLUDE-RESOURCE-TYPES",
38+
}
39+
40+
VALIDATE_POLICY_SPECIFIC_OPTIONAL_INPUTS = {
41+
"INPUT_ALLOW-EXTERNAL-PRINCIPALS",
42+
"INPUT_TREAT-FINDING-TYPE-AS-BLOCKING",
43+
}
44+
45+
# Excluding the TREAT-FINDINGS-AS-NON-BLOCKING which is a flag and needs special handling
46+
CHECK_NO_NEW_ACCESS_SPECIFIC_OPTIONAL_INPUTS = set()
47+
48+
# Excluding the TREAT-FINDINGS-AS-NON-BLOCKING which is a flag and needs special handling
49+
CHECK_ACCESS_NOT_GRANTED_SPECIFIC_OPTIONAL_INPUTS = set()
50+
51+
52+
VALID_POLICY_CHECK_TYPES = [
53+
VALIDATE_POLICY,
54+
CHECK_NO_NEW_ACCESS,
55+
CHECK_ACCESS_NOT_GRANTED,
56+
]
57+
58+
# Name of the output defined in the GitHub action schema
59+
ACTION_OUTPUT_RESULT = "result"
60+
61+
62+
def main():
63+
policy_check = get_policy_check_type()
64+
required_inputs = get_required_inputs(policy_check)
65+
optional_inputs = get_optional_inputs(policy_check)
66+
command_lst = build_command(
67+
policy_check, required_inputs=required_inputs, optional_inputs=optional_inputs
68+
)
69+
result = execute_command(command_lst)
70+
formatted_result = format_result(result)
71+
set_github_action_output(ACTION_OUTPUT_RESULT, formatted_result)
72+
return
73+
74+
75+
# Get the policy check name
76+
def get_policy_check_type():
77+
policy_check = os.environ[POLICY_CHECK_TYPE]
78+
if policy_check not in VALID_POLICY_CHECK_TYPES:
79+
raise ValueError(
80+
"Invalid value of policy-check-type: {}. Valid values are: {}".format(
81+
policy_check, VALID_POLICY_CHECK_TYPES
82+
)
83+
)
84+
return policy_check
85+
86+
87+
def get_flag_name(val):
88+
return val.removeprefix("INPUT_").lower()
89+
90+
91+
def get_required_inputs(policy_check):
92+
required_inputs = {}
93+
check_specific_required_inputs = None
94+
if policy_check == VALIDATE_POLICY:
95+
check_specific_required_inputs = VALIDATE_POLICY_SPECIFIC_REQUIRED_INPUTS
96+
elif policy_check == CHECK_NO_NEW_ACCESS:
97+
check_specific_required_inputs = CHECK_NO_NEW_ACCESS_SPECIFIC_REQUIRED_INPUTS
98+
elif policy_check == CHECK_ACCESS_NOT_GRANTED:
99+
check_specific_required_inputs = (
100+
CHECK_ACCESS_NOT_GRANTED_SPECIFIC_REQUIRED_INPUTS
101+
)
102+
required_inputs = COMMON_REQUIRED_INPUTS.union(check_specific_required_inputs)
103+
return required_inputs
104+
105+
106+
def get_optional_inputs(policy_check):
107+
optional_inputs = {}
108+
check_specific_optional_inputs = None
109+
if policy_check == VALIDATE_POLICY:
110+
check_specific_optional_inputs = VALIDATE_POLICY_SPECIFIC_OPTIONAL_INPUTS
111+
elif policy_check == CHECK_NO_NEW_ACCESS:
112+
check_specific_optional_inputs = CHECK_NO_NEW_ACCESS_SPECIFIC_OPTIONAL_INPUTS
113+
elif policy_check == CHECK_ACCESS_NOT_GRANTED:
114+
check_specific_optional_inputs = (
115+
CHECK_ACCESS_NOT_GRANTED_SPECIFIC_OPTIONAL_INPUTS
116+
)
117+
optional_inputs = check_specific_optional_inputs.union(COMMON_OPTIONAL_INPUTS)
118+
return optional_inputs
119+
120+
121+
def build_command(policy_check_type, required_inputs, optional_inputs):
122+
cli_tool_name = CLI_POLICY_VALIDATOR
123+
command_lst = []
124+
cli_operation_name = (
125+
"validate"
126+
if policy_check_type == VALIDATE_POLICY
127+
else policy_check_type.replace("_", "-").lower()
128+
)
129+
130+
sub_command_required_lst = get_sub_command(required_inputs, True)
131+
sub_command_optional_lst = get_sub_command(optional_inputs, False)
132+
133+
command_lst.append(cli_tool_name)
134+
command_lst.append(cli_operation_name)
135+
command_lst.extend(sub_command_required_lst)
136+
command_lst.extend(sub_command_optional_lst)
137+
138+
treat_findings_as_non_blocking_flag = get_treat_findings_as_non_blocking_flag(
139+
policy_check_type
140+
)
141+
if len(treat_findings_as_non_blocking_flag) > 0:
142+
command_lst.extend(get_treat_findings_as_non_blocking_flag(policy_check_type))
143+
return command_lst
144+
145+
146+
def get_sub_command(inputFields, areRequiredFields):
147+
flags = []
148+
149+
for input in inputFields:
150+
# The default values to these environment variable when passed to docker is empty string through GitHub Actions
151+
if os.environ[input] != "":
152+
flag_name = get_flag_name(input)
153+
flags.extend(["--{}".format(flag_name), os.environ[input]])
154+
elif areRequiredFields:
155+
raise ValueError("Missing value for required field: {}", input)
156+
157+
return flags
158+
159+
160+
def get_treat_findings_as_non_blocking_flag(policy_check):
161+
# This is specific to custom checks - CheckAccessNotGranted & CheckNoNewAccess
162+
if policy_check in (CHECK_ACCESS_NOT_GRANTED, CHECK_NO_NEW_ACCESS):
163+
val = os.environ[TREAT_FINDINGS_AS_NON_BLOCKING]
164+
if val == "True":
165+
return ["--{}".format(get_flag_name(TREAT_FINDINGS_AS_NON_BLOCKING))]
166+
elif val == "False":
167+
return ""
168+
else:
169+
raise ValueError(
170+
"Invalid value for {}: {}".format(TREAT_FINDINGS_AS_NON_BLOCKING, val)
171+
)
172+
return ""
173+
174+
175+
def execute_command(command):
176+
try:
177+
result = subprocess.run(
178+
command, check=True, stdout=subprocess.PIPE, encoding="utf-8"
179+
).stdout
180+
return result
181+
except subprocess.CalledProcessError as err:
182+
print(
183+
"error code: {}, traceback: {}, output: {}".format(
184+
err.returncode, err.with_traceback, err.output
185+
)
186+
)
187+
raise
188+
except Exception as err:
189+
print(f"Unexpected {err=}, {type(err)=}")
190+
raise
191+
192+
193+
def format_result(result):
194+
result = re.sub(r"[\n\t\s]*", "", result)
195+
print("result={}".format(result))
196+
return result
197+
198+
199+
# Output value should be set by writing to the outputs in the environment file
200+
# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter
201+
def set_github_action_output(output_name, output_value):
202+
with open(os.path.abspath(os.environ["GITHUB_OUTPUT"]), "a") as f:
203+
f.write(f"{output_name}={output_value}")
204+
return
205+
206+
207+
if __name__ == "__main__":
208+
try:
209+
main()
210+
except Exception as e:
211+
traceback.print_exc()
212+
print(f"ERROR: Unexpected error occurred. {str(e)}", file=sys.stderr)
213+
exit(1)

0 commit comments

Comments
 (0)