├── .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 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 |
InputsDescriptionOptionsRequiredApplies To which policy-check-type
VALIDATE_POLICYCHECK_NO_NEW_ACCESSCHECK_ACCESS_NOT_GRANTEDCHECK_NO_PUBLIC_ACCESS
policy-check-typeName of the policy check.
Note: Each value corresponds to an IAM Access Analyzer API.
- ValidatePolicy
- CheckNoNewAccess
- CheckAccessNotGranted
- CheckNoPublicAccess
VALIDATE_POLICY, CHECK_NO_NEW_ACCESS, CHECK_ACCESS_NOT_GRANTED, CHECK_NO_PUBLIC_ACCESS.Yes
template-pathThe path to the CloudFormation template.FILE_PATH.jsonYes
regionThe destination region the resources will be deployed to.REGIONYes
parametersKeys and values for CloudFormation template parameters. Only parameters that are referenced by IAM policies in the template are required.KEY=VALUE [KEY=VALUE ...]No
template-configuration-fileA 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.FILE_PATH.jsonNo
ignore-findingAllow 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.FINDING_CODE,RESOURCE_NAME,RESOURCE_NAME.FINDING_CODENo
treat-findings-as-non-blockingBy 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.
No
actionsList 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
ACTION,ACTION,ACTIONNo
resourcesList 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
RESOURCE,RESOURCE,RESOURCENo
reference-policyA 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".
FILE_PATH.jsonNo
reference-policy-typeThe 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"
REFERENCE_POLICY_TYPENo
treat-finding-type-as-blockingSpecify 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".
ERROR,SECURITY_WARNING,WARNING,SUGGESTION,NONENo
allow-external-principalsA 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).ACCOUNT,ARNNo
allow-dynamic-ref-without-versionOverride 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. No
exclude-resource-typesList 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.AWS::SERVICE::RESOURCE, AWS::SERVICE::RESOURCENo
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 | --------------------------------------------------------------------------------