├── .github
└── workflows
│ └── unittest.yaml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── action.yaml
├── main.py
└── test_cfn.py
/.github/workflows/unittest.yaml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | unittest:
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | os: [ubuntu-latest, windows-latest]
11 | python: ["3.9", "3.10", "3.11", "3.12"]
12 |
13 | steps:
14 | - uses: actions/checkout@v4.1.1
15 | - name: Setup Python
16 | uses: actions/setup-python@v5.1.0
17 | with:
18 | python-version: ${{ matrix.python }}
19 | - name: Install pytest and parameterized
20 | run: pip install pytest parameterized
21 | - name: Run test
22 | run: python -m unittest test_cfn.py
23 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
4 | opensource-codeofconduct@amazon.com with any additional questions or comments.
5 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
4 | documentation, we greatly value feedback and contributions from our community.
5 |
6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
7 | information to effectively respond to your bug report or contribution.
8 |
9 |
10 | ## Reporting Bugs/Feature Requests
11 |
12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features.
13 |
14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
16 |
17 | * A reproducible test case or series of steps
18 | * The version of our code being used
19 | * Any modifications you've made relevant to the bug
20 | * Anything unusual about your environment or deployment
21 |
22 |
23 | ## Contributing via Pull Requests
24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
25 |
26 | 1. You are working against the latest source on the *main* branch.
27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
29 |
30 | To send us a pull request, please:
31 |
32 | 1. Fork the repository.
33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
34 | 3. Ensure local tests pass.
35 | 4. Commit to your fork using clear commit messages.
36 | 5. Send us a pull request, answering any default questions in the pull request interface.
37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
38 |
39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
41 |
42 |
43 | ## Finding contributions to work on
44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
45 |
46 |
47 | ## Code of Conduct
48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
50 | opensource-codeofconduct@amazon.com with any additional questions or comments.
51 |
52 |
53 | ## Security issue notifications
54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
55 |
56 |
57 | ## Licensing
58 |
59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
60 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
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.36
5 |
6 | COPY main.py /main.py
7 |
8 | ENTRYPOINT ["python3", "/main.py"]
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT No Attribution
2 |
3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so.
10 |
11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17 |
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Policy Validator for AWS IAM Policies in CloudFormation Templates
2 |
3 | A GitHub Action that takes an [AWS CloudFormation](https://aws.amazon.com/cloudformation/) template, parses the IAM policies attached to IAM roles, users, groups, and resources then runs them through IAM Access Analyzer [policy validation](https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-policy-validation.html) and (optionally) [custom policy checks](https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-custom-policy-checks.html). Note that a charge is associated with each custom policy check. For more details about pricing, see [IAM Access Analyzer pricing](https://aws.amazon.com/iam/access-analyzer/pricing/).
4 |
5 | ## Inputs
6 |
7 | See [action.yml](action.yaml) for the full documentation for this action's inputs and outputs.
8 |
9 |
10 |
11 |
12 |
13 | Inputs |
14 | Description |
15 | Options |
16 | Required |
17 | Applies To which policy-check-type |
18 |
19 |
20 | VALIDATE_POLICY |
21 | CHECK_NO_NEW_ACCESS |
22 | CHECK_ACCESS_NOT_GRANTED |
23 | CHECK_NO_PUBLIC_ACCESS |
24 |
25 |
26 | policy-check-type |
27 | Name of the policy check. Note: Each value corresponds to an IAM Access Analyzer API. - ValidatePolicy - CheckNoNewAccess - CheckAccessNotGranted - CheckNoPublicAccess |
28 | VALIDATE_POLICY, CHECK_NO_NEW_ACCESS, CHECK_ACCESS_NOT_GRANTED, CHECK_NO_PUBLIC_ACCESS. |
29 | Yes |
30 | ✅ |
31 | ✅ |
32 | ✅ |
33 | ✅ |
34 |
35 |
36 | template-path |
37 | The path to the CloudFormation template. |
38 | FILE_PATH.json |
39 | Yes |
40 | ✅ |
41 | ✅ |
42 | ✅ |
43 | ✅ |
44 |
45 |
46 | region |
47 | The destination region the resources will be deployed to. |
48 | REGION |
49 | Yes |
50 | ✅ |
51 | ✅ |
52 | ✅ |
53 | ✅ |
54 |
55 |
56 | parameters |
57 | Keys and values for CloudFormation template parameters. Only parameters that are referenced by IAM policies in the template are required. |
58 | KEY=VALUE [KEY=VALUE ...] |
59 | No |
60 | ✅ |
61 | ✅ |
62 | ✅ |
63 | ✅ |
64 |
65 |
66 | template-configuration-file |
67 | 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. |
68 | FILE_PATH.json |
69 | No |
70 | ✅ |
71 | ✅ |
72 | ✅ |
73 | ✅ |
74 |
75 |
76 | ignore-finding |
77 | 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. |
78 | FINDING_CODE,RESOURCE_NAME,RESOURCE_NAME.FINDING_CODE |
79 | No |
80 | ✅ |
81 | ✅ |
82 | ✅ |
83 | ✅ |
84 |
85 |
86 | treat-findings-as-non-blocking |
87 | By default, the tool will exit with a non-zero exit code when it detects any findings. Set this flag to exit with an exit code of 0 when it detects findings. You can use this to run new checks in a shadow or log only mode before enforcing them.
This attribute is considered only when policy-check-type is "CHECK_NO_NEW_ACCESS", "CHECK_ACCESS_NOT_GRANTED", or "CHECK_NO_PUBLIC_ACCESS. |
88 | |
89 | No |
90 | ❌ |
91 | ✅ |
92 | ✅ |
93 | ✅ |
94 |
95 |
96 | actions |
97 | List of comma-separated actions. Example format - ACTION,ACTION,ACTION.
This attribute is only considered when policy-check-type is "CHECK_ACCESS_NOT_GRANTED". At least one of "actions" or "resources" must be provided |
98 | ACTION,ACTION,ACTION |
99 | No |
100 | ❌ |
101 | ❌ |
102 | ✅ |
103 | ❌ |
104 |
105 |
106 | resources |
107 | List of comma-separated resource ARNs. Example format - RESOURCE,RESOURCE,RESOURCE.
This attribute is only considered when policy-check-type is "CHECK_ACCESS_NOT_GRANTED". At least one of "actions" or "resources" must be provided |
108 | RESOURCE,RESOURCE,RESOURCE |
109 | No |
110 | ❌ |
111 | ❌ |
112 | ✅ |
113 | ❌ |
114 |
115 |
116 | reference-policy |
117 | A JSON formatted file that specifies the path to the reference policy that is used for a permissions comparison.
This attribute is only considered and required when policy-check-type is "CHECK_NO_NEW_ACCESS". |
118 | FILE_PATH.json |
119 | No |
120 | ❌ |
121 | ✅ |
122 | ❌ |
123 | ❌ |
124 |
125 |
126 | reference-policy-type |
127 | The policy type associated with the IAM policy under analysis and the reference policy. Valid values: IDENTITY, RESOURCE.
This attribute is only considered and required when policy-check-type is "CHECK_NO_NEW_ACCESS" |
128 | REFERENCE_POLICY_TYPE |
129 | No |
130 | ❌ |
131 | ✅ |
132 | ❌ |
133 | ❌ |
134 |
135 |
136 | treat-finding-type-as-blocking |
137 | 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 only considered when policy-check-type is "VALIDATE_POLICY". |
138 | ERROR,SECURITY_WARNING,WARNING,SUGGESTION,NONE |
139 | No |
140 | ✅ |
141 | ❌ |
142 | ❌ |
143 | ❌ |
144 |
145 |
146 | allow-external-principals |
147 | 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). |
148 | ACCOUNT,ARN |
149 | No |
150 | ✅ |
151 | ❌ |
152 | ❌ |
153 | ❌ |
154 |
155 |
156 | allow-dynamic-ref-without-version |
157 | 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. |
158 | |
159 | No |
160 | ✅ |
161 | ✅ |
162 | ✅ |
163 | ✅ |
164 |
165 |
166 | exclude-resource-types |
167 | 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. |
168 | AWS::SERVICE::RESOURCE, AWS::SERVICE::RESOURCE |
169 | No |
170 | ✅ |
171 | ✅ |
172 | ✅ |
173 | ✅ |
174 |
175 |
176 |
177 |
178 |
179 | ### Example Usage
180 |
181 | **Before each of the following examples, make sure to include the following:**
182 |
183 | - Setting up the role: Role used in the GitHub workflow should have necessary permissions required
184 | - to be called from the GitHub workflows - setup OpenID Connect(OIDC) provider and IAM role & Trust policy as described in step 1 & 2 in [this](https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/) blog
185 | - to call the AWS APIs for the policy checks - ValidatePolicy, CheckNoNewAccess, CheckAccessNotGranted, CheckNoPublicAccess. Refer [this](https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-checks-validating-policies.html) page for more details
186 |
187 | ```
188 | - name: Checkout Repo
189 | uses: actions/checkout@v4
190 | - name: Configure AWS Credentials
191 | uses: aws-actions/configure-aws-credentials@v4
192 | with:
193 | role-to-assume: ${{ secrets.POLICY_VALIDATOR_ROLE }} # Role with permissions to invoke access-analyzer:ValidatePolicy,access-analyzer:CheckNoNewAccess, access-analyzer:CheckAccessNotGranted, access-analyzer:CheckNoPublicAccess
194 | aws-region: aws-example-region
195 | ```
196 | #### Getting started using starter workflows
197 |
198 | To get started quickly, add a starter workflow to the `.github/workflows` directory of your repository. In order to do that, do the following -
199 |
200 | - Navigate to `Actions` tab of the GitHub repository
201 | - Click on `New Workflow` button
202 | - Search for `Policy Validator for CloudFormation` in the search bar
203 | - Click on `Configure` button
204 | - Pass the appropriate inputs to the workflow and modify it accordingly
205 | - Click on `Commit changes` to commit your changes
206 | - Start using the GitHub actions!
207 |
208 | Please find the starter workflow [here](https://github.com/actions/starter-workflows/blob/main/code-scanning/policy-validator-cfn.yaml)
209 |
210 | #### Using `VALIDATE_POLICY` CHECK
211 |
212 | ```
213 | - name: Run VALIDATE_POLICY Check
214 | id: run-validate-policy
215 | uses: aws-actions/cloudformation-aws-iam-policy-validator@v1.0.1
216 | with:
217 | policy-check-type: 'VALIDATE_POLICY'
218 | template-path: file-path-to-the-cfn-templates
219 | region: aws-example-region
220 |
221 | ```
222 |
223 | #### Using for the `CHECK_NO_NEW_ACCESS` CHECK
224 |
225 | ```
226 | - name: Run CHECK_NO_NEW_ACCESS check
227 | id: run-check-no-new-access
228 | uses: aws-actions/cloudformation-aws-iam-policy-validator@v1.0.1
229 | with:
230 | policy-check-type: 'CHECK_NO_NEW_ACCESS'
231 | template-path: file-path-to-the-cfn-templates
232 | reference-policy: file-path-to-the-reference-policy
233 | reference-policy-type: policy-type-of-reference-policy
234 | region: aws-example-region
235 | ```
236 |
237 | #### Using for the `CHECK_ACCESS_NOT_GRANTED` CHECK
238 |
239 | ```
240 | - name: Run CHECK_ACCESS_NOT_GRANTED check
241 | id: run-check-access-not-granted
242 | uses: aws-actions/cloudformation-aws-iam-policy-validator@v1.0.1
243 | with:
244 | policy-check-type: 'CHECK_ACCESS_NOT_GRANTED'
245 | template-path: file-path-to-the-cfn-templates
246 | actions: "action1, action2.."
247 | resources: "resource1, resource2.."
248 | region: aws-example-region
249 | ```
250 |
251 | #### Using for the `CHECK_NO_PUBLIC_ACCESS` CHECK
252 |
253 | ```
254 | - name: Run CHECK_NO_PUBLIC_ACCESS check
255 | id: run-check-no-public-access
256 | uses: aws-actions/cloudformation-aws-iam-policy-validator@v1.0.1
257 | with:
258 | policy-check-type: 'CHECK_NO_PUBLIC_ACCESS'
259 | template-path: file-path-to-the-cfn-templates
260 | region: aws-example-region
261 | ```
262 |
263 | ## Security
264 |
265 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
266 |
267 | ## License
268 |
269 | This library is licensed under the MIT-0 License. See the LICENSE file.
270 |
271 |
--------------------------------------------------------------------------------
/action.yaml:
--------------------------------------------------------------------------------
1 | name: 'Policy checks to validate AWS IAM policies in CloudFormation templates" Action For GitHub Actions'
2 | description: "Validate IAM Policies in CFN templates using ValidatePolicy, CheckAccessNotGranted & CheckNoNewAccess API in Access Analyzer"
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 when policy-check-type is "CHECK_ACCESS_NOT_GRANTED". At least one of "actions" or "resources" must be specified.'
24 | resources:
25 | description: 'List of comma-separated resource ARNs. Example format - RESOURCE,RESOURCE,RESOURCE. This attribute is considered when policy-check-type is "CHECK_ACCESS_NOT_GRANTED" At least one of "actions" or "resources" must be specified.'
26 | reference-policy:
27 | 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"'
28 | reference-policy-type:
29 | 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"'
30 | treat-finding-type-as-blocking:
31 | 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"'
32 | treat-findings-as-non-blocking:
33 | 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"'
34 | default: "False"
35 | allow-external-principals:
36 | 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"'
37 | allow-dynamic-ref-without-version:
38 | 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."
39 | exclude-resource-types:
40 | 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"
41 | outputs:
42 | result:
43 | description: "Result of the policy checks"
44 | runs:
45 | using: "docker"
46 | image: Dockerfile
47 | args:
48 | - ${{ inputs.policy-check-type}}
49 | - ${{ inputs.template-path }}
50 | - ${{ inputs.region }}
51 | - ${{ inputs.parameters }}
52 | - ${{ inputs.template-configuration-file }}
53 | - ${{ inputs.ignore-finding }}
54 | - ${{ inputs.actions }}
55 | - ${{ inputs.reference-policy }}
56 | - ${{ inputs.reference-policy-type }}
57 | - ${{ inputs.treat-finding-type-as-blocking }}
58 | - ${{ inputs.treat-findings-as-non-blocking }}
59 | - ${{ inputs.allow-external-principals }}
60 | - ${{ inputs.allow-dynamic-ref-without-version }}
61 | - ${{ inputs.exclude-resource-types }}
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
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 | CHECK_NO_PUBLIC_ACCESS = "CHECK_NO_PUBLIC_ACCESS"
11 |
12 | CLI_POLICY_VALIDATOR = "cfn-policy-validator"
13 |
14 | TREAT_FINDINGS_AS_NON_BLOCKING = "INPUT_TREAT-FINDINGS-AS-NON-BLOCKING"
15 |
16 | POLICY_CHECK_TYPE = "INPUT_POLICY-CHECK-TYPE"
17 |
18 | # excluding the "INPUT_POLICY-CHECK-TYPE". Contains only other required inputs in cfn-policy-validator
19 | COMMON_REQUIRED_INPUTS = {"INPUT_TEMPLATE-PATH", "INPUT_REGION"}
20 |
21 | VALIDATE_POLICY_SPECIFIC_REQUIRED_INPUTS = set()
22 |
23 | CHECK_NO_NEW_ACCESS_SPECIFIC_REQUIRED_INPUTS = {
24 | "INPUT_TEMPLATE-PATH",
25 | "INPUT_REGION",
26 | "INPUT_REFERENCE-POLICY",
27 | "INPUT_REFERENCE-POLICY-TYPE",
28 | }
29 |
30 | # Use tuple to specify that at least one of the enclosed inputs is required.
31 | CHECK_ACCESS_NOT_GRANTED_SPECIFIC_REQUIRED_INPUTS = {("INPUT_ACTIONS", "INPUT_RESOURCES")}
32 |
33 | CHECK_NO_PUBLIC_ACCESS_SPECIFIC_REQUIRED_INPUTS = set()
34 |
35 | # excluding the "INPUT_POLICY-CHECK-TYPE". Contains only other required inputs in cfn-policy-validator
36 | COMMON_OPTIONAL_INPUTS = {
37 | "INPUT_PARAMETERS",
38 | "INPUT_TEMPLATE-CONFIGURATION-FILE",
39 | "INPUT_IGNORE-FINDING",
40 | "INPUT_ALLOW-DYNAMIC-REF-WITHOUT-VERSION",
41 | "INPUT_EXCLUDE-RESOURCE-TYPES",
42 | }
43 |
44 | VALIDATE_POLICY_SPECIFIC_OPTIONAL_INPUTS = {
45 | "INPUT_ALLOW-EXTERNAL-PRINCIPALS",
46 | "INPUT_TREAT-FINDING-TYPE-AS-BLOCKING",
47 | }
48 |
49 | # Excluding the TREAT-FINDINGS-AS-NON-BLOCKING which is a flag and needs special handling
50 | CHECK_NO_NEW_ACCESS_SPECIFIC_OPTIONAL_INPUTS = set()
51 |
52 | # Excluding the TREAT-FINDINGS-AS-NON-BLOCKING which is a flag and needs special handling
53 | CHECK_ACCESS_NOT_GRANTED_SPECIFIC_OPTIONAL_INPUTS = set()
54 |
55 | # Excluding the TREAT-FINDINGS-AS-NON-BLOCKING which is a flag and needs special handling
56 | CHECK_NO_PUBLIC_ACCESS_SPECIFIC_OPTIONAL_INPUTS = set()
57 |
58 |
59 | VALID_POLICY_CHECK_TYPES = [
60 | VALIDATE_POLICY,
61 | CHECK_NO_NEW_ACCESS,
62 | CHECK_ACCESS_NOT_GRANTED,
63 | CHECK_NO_PUBLIC_ACCESS
64 | ]
65 |
66 | # Name of the output defined in the GitHub action schema
67 | ACTION_OUTPUT_RESULT = "result"
68 |
69 |
70 | def main():
71 | policy_check = get_policy_check_type()
72 | required_inputs = get_required_inputs(policy_check)
73 | optional_inputs = get_optional_inputs(policy_check)
74 | command_lst = build_command(
75 | policy_check, required_inputs=required_inputs, optional_inputs=optional_inputs
76 | )
77 | result = execute_command(command_lst)
78 | set_output(result)
79 | return
80 |
81 |
82 | # Get the policy check name
83 | def get_policy_check_type():
84 | policy_check = os.environ[POLICY_CHECK_TYPE]
85 | if policy_check not in VALID_POLICY_CHECK_TYPES:
86 | raise ValueError(
87 | "Invalid value of policy-check-type: {}. Valid values are: {}".format(
88 | policy_check, VALID_POLICY_CHECK_TYPES
89 | )
90 | )
91 | return policy_check
92 |
93 |
94 | def get_flag_name(val):
95 | return val.removeprefix("INPUT_").lower()
96 |
97 |
98 | def get_required_inputs(policy_check):
99 | required_inputs = {}
100 | check_specific_required_inputs = None
101 | if policy_check == VALIDATE_POLICY:
102 | check_specific_required_inputs = VALIDATE_POLICY_SPECIFIC_REQUIRED_INPUTS
103 | elif policy_check == CHECK_NO_NEW_ACCESS:
104 | check_specific_required_inputs = CHECK_NO_NEW_ACCESS_SPECIFIC_REQUIRED_INPUTS
105 | elif policy_check == CHECK_ACCESS_NOT_GRANTED:
106 | check_specific_required_inputs = (
107 | CHECK_ACCESS_NOT_GRANTED_SPECIFIC_REQUIRED_INPUTS
108 | )
109 | elif policy_check == CHECK_NO_PUBLIC_ACCESS:
110 | check_specific_required_inputs = (
111 | CHECK_NO_PUBLIC_ACCESS_SPECIFIC_REQUIRED_INPUTS
112 | )
113 | required_inputs = COMMON_REQUIRED_INPUTS.union(check_specific_required_inputs)
114 | return required_inputs
115 |
116 |
117 | def get_optional_inputs(policy_check):
118 | optional_inputs = {}
119 | check_specific_optional_inputs = None
120 | if policy_check == VALIDATE_POLICY:
121 | check_specific_optional_inputs = VALIDATE_POLICY_SPECIFIC_OPTIONAL_INPUTS
122 | elif policy_check == CHECK_NO_NEW_ACCESS:
123 | check_specific_optional_inputs = CHECK_NO_NEW_ACCESS_SPECIFIC_OPTIONAL_INPUTS
124 | elif policy_check == CHECK_ACCESS_NOT_GRANTED:
125 | check_specific_optional_inputs = (
126 | CHECK_ACCESS_NOT_GRANTED_SPECIFIC_OPTIONAL_INPUTS
127 | )
128 | elif policy_check == CHECK_NO_PUBLIC_ACCESS:
129 | check_specific_optional_inputs = (
130 | CHECK_NO_PUBLIC_ACCESS_SPECIFIC_OPTIONAL_INPUTS
131 | )
132 | optional_inputs = check_specific_optional_inputs.union(COMMON_OPTIONAL_INPUTS)
133 | return optional_inputs
134 |
135 |
136 | def build_command(policy_check_type, required_inputs, optional_inputs):
137 | cli_tool_name = CLI_POLICY_VALIDATOR
138 | command_lst = []
139 | cli_operation_name = (
140 | "validate"
141 | if policy_check_type == VALIDATE_POLICY
142 | else policy_check_type.replace("_", "-").lower()
143 | )
144 |
145 | sub_command_required_lst = get_sub_command(required_inputs, True)
146 | sub_command_optional_lst = get_sub_command(optional_inputs, False)
147 |
148 | command_lst.append(cli_tool_name)
149 | command_lst.append(cli_operation_name)
150 | command_lst.extend(sub_command_required_lst)
151 | command_lst.extend(sub_command_optional_lst)
152 |
153 | treat_findings_as_non_blocking_flag = get_treat_findings_as_non_blocking_flag(
154 | policy_check_type
155 | )
156 | if len(treat_findings_as_non_blocking_flag) > 0:
157 | command_lst.extend(get_treat_findings_as_non_blocking_flag(policy_check_type))
158 | return command_lst
159 |
160 |
161 | def get_sub_command(inputFields, areRequiredFields):
162 | flags = []
163 |
164 | for input in inputFields:
165 | # Checking that at least one of a set of required fields is provided
166 | if isinstance(input, tuple):
167 | provided = False
168 | for field in input:
169 | if os.environ[field] != "":
170 | flag_name = get_flag_name(field)
171 | flags.extend(["--{}".format(flag_name), os.environ[field]])
172 | provided = True
173 | if provided == False:
174 | raise ValueError(f"Missing value for at least one of the required fields: {str(input)}")
175 | else:
176 | # The default values to these environment variable when passed to docker is empty string through GitHub Actions
177 | if os.environ[input] != "":
178 | flag_name = get_flag_name(input)
179 | flags.extend(["--{}".format(flag_name), os.environ[input]])
180 | elif areRequiredFields:
181 | raise ValueError("Missing value for required field: {}", input)
182 |
183 | return flags
184 |
185 |
186 | def get_treat_findings_as_non_blocking_flag(policy_check):
187 | # This is specific to custom checks - CheckAccessNotGranted & CheckNoNewAccess
188 | if policy_check in (CHECK_ACCESS_NOT_GRANTED, CHECK_NO_NEW_ACCESS, CHECK_NO_PUBLIC_ACCESS):
189 | val = os.environ[TREAT_FINDINGS_AS_NON_BLOCKING]
190 | if val == "True":
191 | return ["--{}".format(get_flag_name(TREAT_FINDINGS_AS_NON_BLOCKING))]
192 | elif val == "False":
193 | return ""
194 | else:
195 | raise ValueError(
196 | "Invalid value for {}: {}".format(TREAT_FINDINGS_AS_NON_BLOCKING, val)
197 | )
198 | return ""
199 |
200 |
201 | def execute_command(command):
202 | try:
203 | result = subprocess.run(
204 | command, check=True, stdout=subprocess.PIPE, encoding="utf-8"
205 | ).stdout
206 | return result
207 | except subprocess.CalledProcessError as err:
208 | print(
209 | "error code: {}, traceback: {}, output: {}".format(
210 | err.returncode, err.with_traceback, err.output
211 | )
212 | )
213 |
214 | if err.returncode == 2:
215 | set_output(err.output)
216 | raise
217 | except Exception as err:
218 | print(f"Unexpected {err=}, {type(err)=}")
219 | raise
220 |
221 |
222 | def set_output(val):
223 | formatted_result = format_result(val)
224 | set_github_action_output(ACTION_OUTPUT_RESULT, formatted_result)
225 | return
226 |
227 |
228 | def format_result(result):
229 | result = re.sub(r"[\n\t]*|\s{2,}", "", result)
230 | print("result={}".format(result))
231 | return result
232 |
233 |
234 | # Output value should be set by writing to the outputs in the environment file
235 | # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter
236 | def set_github_action_output(output_name, output_value):
237 | with open(os.path.abspath(os.environ["GITHUB_OUTPUT"]), "a") as f:
238 | f.write(f"{output_name}={output_value}")
239 | return
240 |
241 |
242 | if __name__ == "__main__":
243 | try:
244 | main()
245 | except Exception as e:
246 | traceback.print_exc()
247 | print(f"ERROR: Unexpected error occurred. {str(e)}", file=sys.stderr)
248 | exit(1)
--------------------------------------------------------------------------------
/test_cfn.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import unittest
4 | import pytest
5 | from parameterized import parameterized
6 | from main import get_policy_check_type, get_required_inputs, get_optional_inputs, get_sub_command
7 | from main import get_treat_findings_as_non_blocking_flag, build_command, execute_command, set_output
8 |
9 | from main import CLI_POLICY_VALIDATOR, POLICY_CHECK_TYPE, VALIDATE_POLICY, CHECK_NO_NEW_ACCESS, CHECK_ACCESS_NOT_GRANTED, CHECK_NO_PUBLIC_ACCESS
10 | from main import COMMON_REQUIRED_INPUTS, TREAT_FINDINGS_AS_NON_BLOCKING
11 |
12 | from unittest.mock import patch
13 |
14 | INVALID_POLICY_CHECK = "INVALID_POLICY_CHECK"
15 |
16 | class CfnpvTest(unittest.TestCase):
17 | # case 1: test_get_type_ENVIRON_NOT_SET: failure expected because required os.environ[] are not set
18 | def test_get_type_ENVIRON_NOT_SET(self):
19 | self.assertRaises(KeyError, get_policy_check_type)
20 |
21 | # case 2: test_get_type_INVALID_POLICY_CHECK: failure expected because os.environ[] is set to an invalid value
22 | def test_get_type_INVALID_POLICY_CHECK(self):
23 | os.environ[POLICY_CHECK_TYPE] = INVALID_POLICY_CHECK
24 | self.assertRaises(ValueError, get_policy_check_type)
25 |
26 | # case 3, 4, 5: test_get_type_WITH_VALIDATE_POLICY: success with valid POLICY_CHECK_TYPE
27 | @parameterized.expand([VALIDATE_POLICY, CHECK_NO_NEW_ACCESS, CHECK_ACCESS_NOT_GRANTED, CHECK_NO_PUBLIC_ACCESS])
28 | def test_get_type_WITH_VALID_POLICY_CHECK(self, policy_check_type):
29 | os.environ[POLICY_CHECK_TYPE] = policy_check_type
30 | policy_type = get_policy_check_type()
31 | self.assertEqual(policy_type, policy_check_type)
32 |
33 | # case 6: test_get_required_input_INVALID_POLICY_CHECK: failure expected because an invalid policy_check_type is provided
34 | @unittest.expectedFailure
35 | def test_get_required_input_INVALID_POLICY_CHECK(self):
36 | policy_check = INVALID_POLICY_CHECK
37 | self.assertEqual(get_required_inputs(policy_check), "")
38 |
39 | # case 7, 8, 9: test_get_required_input_WITH_VALIDATE_POLICY: success as a valid POLICY_CHECK_TYPE is provided: VALIDATE_POLICY
40 | @parameterized.expand([VALIDATE_POLICY, CHECK_NO_NEW_ACCESS, CHECK_ACCESS_NOT_GRANTED, CHECK_NO_PUBLIC_ACCESS])
41 | def test_get_required_input_WITH_VALID_POLICY_CHECK(self, policy_check_type):
42 | result = get_required_inputs(policy_check_type)
43 | if policy_check_type == CHECK_NO_NEW_ACCESS:
44 | self.assertEqual(result, {"INPUT_TEMPLATE-PATH", "INPUT_REGION", "INPUT_REFERENCE-POLICY", "INPUT_REFERENCE-POLICY-TYPE"})
45 | elif policy_check_type == CHECK_ACCESS_NOT_GRANTED:
46 | self.assertEqual(result, {"INPUT_TEMPLATE-PATH", "INPUT_REGION", ("INPUT_ACTIONS", "INPUT_RESOURCES")})
47 | else:
48 | self.assertEqual(result, {"INPUT_TEMPLATE-PATH", "INPUT_REGION"})
49 |
50 | # case 10: test_get_optional_input_INVALID_POLICY_CHECK: failure expected because an invalid policy_check_type is provided
51 | @unittest.expectedFailure
52 | def test_get_optional_input_INVALID_POLICY_CHECK(self):
53 | policy_check = INVALID_POLICY_CHECK
54 | result = get_optional_inputs(policy_check)
55 | assertEqual(result, {"INPUT_PARAMETERS", "INPUT_TEMPLATE-CONFIGURATION-FILE", "INPUT_IGNORE-FINDING",
56 | "INPUT_ALLOW-DYNAMIC-REF-WITHOUT-VERSION", "INPUT_EXCLUDE-RESOURCE-TYPES"})
57 |
58 | # case 11, 12, 13: test_get_optional_input_WITH_VALID_POLICY_CHECK: success as a valid POLICY_CHECK_TYPE is provided
59 | @parameterized.expand([VALIDATE_POLICY, CHECK_NO_NEW_ACCESS, CHECK_ACCESS_NOT_GRANTED, CHECK_NO_NEW_ACCESS])
60 | def test_get_optional_input_WITH_VALID_POLICY_CHECK(self, policy_check_type):
61 | result = get_optional_inputs(policy_check_type)
62 | if policy_check_type == VALIDATE_POLICY:
63 | self.assertEqual(result, {"INPUT_ALLOW-EXTERNAL-PRINCIPALS", "INPUT_TREAT-FINDING-TYPE-AS-BLOCKING",
64 | "INPUT_PARAMETERS", "INPUT_TEMPLATE-CONFIGURATION-FILE", "INPUT_IGNORE-FINDING",
65 | "INPUT_ALLOW-DYNAMIC-REF-WITHOUT-VERSION", "INPUT_EXCLUDE-RESOURCE-TYPES"})
66 | else:
67 | self.assertEqual(result, {"INPUT_PARAMETERS", "INPUT_TEMPLATE-CONFIGURATION-FILE", "INPUT_IGNORE-FINDING",
68 | "INPUT_ALLOW-DYNAMIC-REF-WITHOUT-VERSION", "INPUT_EXCLUDE-RESOURCE-TYPES"})
69 |
70 | # case 14: test_get_sub_command_ENVIRON_NOT_SET: failure expected because required os.environ[]s are not set
71 | def test_get_sub_command_ENVIRON_NOT_SET(self):
72 | os.environ['INPUT_REGION'] = ''
73 | required = ['INPUT_REGION']
74 | self.assertRaises(ValueError, get_sub_command, required, True)
75 |
76 | # case 15: test_get_sub_command_MEET_REQUIRED_INPUTS: success as valid inputs are provided.
77 | def test_get_sub_command_MEET_REQUIRED_INPUTS(self):
78 | cfnt = os.getcwd() + './main.py'
79 | os.environ['INPUT_TEMPLATE-PATH'] = cfnt
80 | os.environ['INPUT_REGION'] = 'us-west-2'
81 | required = {"INPUT_TEMPLATE-PATH", "INPUT_REGION"}
82 | expected = ['--template-path', cfnt, '--region', 'us-west-2']
83 | flags = get_sub_command(required, True)
84 | self.assertEqual(set(flags), set(expected))
85 |
86 | # case 16: test_get_treat_findings_as_non_blocking_flag_ENVIRON_NOT_SET: failure expected because os.environ[TREAT_FINDINGS_AS_NON_BLOCKING] is not set
87 | def test_get_treat_findings_as_non_blocking_flag_ENVIRON_NOT_SET(self):
88 | os.environ[TREAT_FINDINGS_AS_NON_BLOCKING] = ''
89 | policy_check = CHECK_NO_NEW_ACCESS
90 | self.assertRaises(ValueError, get_treat_findings_as_non_blocking_flag, policy_check)
91 |
92 | # case 17: test_get_treat_findings_as_non_blocking_flag_VALIDATE_POLICY: pass
93 | def test_get_treat_findings_as_non_blocking_flag_VALIDATE_POLICY(self):
94 | policy_check = VALIDATE_POLICY
95 | result = get_treat_findings_as_non_blocking_flag(policy_check)
96 | self.assertEqual(result, "")
97 |
98 | # case 18: test_get_treat_findings_as_non_blocking_flag_CHECK_ACCESS_NOT_GRANTED_True: pass, a string of flag returned
99 | def test_get_treat_findings_as_non_blocking_flag_CHECK_ACCESS_NOT_GRANTED_True(self):
100 | policy_check = CHECK_ACCESS_NOT_GRANTED
101 | os.environ[TREAT_FINDINGS_AS_NON_BLOCKING] = 'True'
102 | result = get_treat_findings_as_non_blocking_flag(policy_check)
103 | self.assertEqual(result, ['--treat-findings-as-non-blocking'])
104 |
105 | # case 19: test_get_treat_findings_as_non_blocking_flag_CHECK_ACCESS_NOT_GRANTED_False: pass, an empty string returned
106 | def test_get_treat_findings_as_non_blocking_flag_CHECK_ACCESS_NOT_GRANTED_False(self):
107 | policy_check = CHECK_ACCESS_NOT_GRANTED
108 | os.environ[TREAT_FINDINGS_AS_NON_BLOCKING] = 'False'
109 | result = get_treat_findings_as_non_blocking_flag(policy_check)
110 | self.assertEqual(result, "")
111 |
112 | # case 20: test_build_command_VALIDATE_POLICY: pass with correct parameters.
113 | def test_build_command_VALIDATE_POLICY(self):
114 | policy_check = VALIDATE_POLICY
115 | cfnt = os.getcwd() + './main.py'
116 | os.environ['INPUT_TEMPLATE-PATH'] = cfnt
117 | os.environ['INPUT_REGION'] = 'us-west-2'
118 | os.environ['TREAT_FINDINGS_AS_NON_BLOCKING'] = 'False'
119 | os.environ['INPUT_IGNORE-FINDING'] = 'PASS_ROLE_WITH_STAR_IN_RESOURCE'
120 | required = {"INPUT_TEMPLATE-PATH", "INPUT_REGION"}
121 | optional = {"INPUT_IGNORE-FINDING"}
122 | command = build_command(policy_check, required, optional)
123 | expected = set([CLI_POLICY_VALIDATOR, 'validate', '--template-path', cfnt, '--region', 'us-west-2', '--ignore-finding', 'PASS_ROLE_WITH_STAR_IN_RESOURCE'])
124 | command_set = set(command)
125 | self.assertEqual(command_set, expected)
126 |
127 | # case 21: test_execute_command_EXCEPTION: exception raised
128 | @patch("subprocess.run", side_effect=Exception("invalid-input"))
129 | def test_execute_command_EXCEPTION(self, mock_run):
130 | with pytest.raises(Exception) as exc:
131 | result = execute_command()
132 | assertNotEqual(str(exc.value).find("invalid-input"), -1)
133 |
134 | # case 21: test_execute_command_VALIDATE: pass with mockrun
135 | @patch("main.subprocess.run")
136 | def test_execute_command_VALIDATE(self, mock_run):
137 | command = [CLI_POLICY_VALIDATOR, 'validate']
138 | expected = "pass"
139 |
140 | completed_process = subprocess.CompletedProcess(args=['command', 'args'], returncode=0, stdout=expected, stderr=b'error')
141 | mock_run.return_value = completed_process
142 |
143 | result = execute_command(command)
144 | self.assertEqual(result, expected)
145 |
146 | # case 23: test_set_output: pass
147 | def test_set_output_WRITE_TO_FILE(self):
148 | os.environ['GITHUB_OUTPUT'] = 'o1'
149 | val = '{"BlockingFindings": [],"NonBlockingFindings": []}'
150 | set_output(val)
151 | res = subprocess.run(["grep", "BlockingFindings", "o1"], check=True, capture_output=True, encoding="utf-8").stdout
152 | subprocess.run(["rm", "o1"])
153 | self.assertNotEqual(res.find("BlockingFindings"), -1)
154 |
155 |
--------------------------------------------------------------------------------